diff --git a/docker/module-testing/Dockerfile b/docker/module-testing/Dockerfile new file mode 100644 index 0000000..f189214 --- /dev/null +++ b/docker/module-testing/Dockerfile @@ -0,0 +1,6 @@ +FROM golang:1.26-alpine + +RUN apk add git + +WORKDIR /project +RUN go mod init project diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 0000000..d3b6546 --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,34 @@ +module git.the-red-comet.org/guy176251/go-reddit/v2 + +go 1.24.0 + +require ( + github.com/andybalholm/brotli v1.2.0 + github.com/enetx/surf v1.0.173 + github.com/google/go-querystring v1.0.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/net v0.49.0 + golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/enetx/g v1.0.202 // indirect + github.com/enetx/http v1.0.24 // indirect + github.com/enetx/http2 v1.0.24 // indirect + github.com/enetx/http3 v1.0.6 // indirect + github.com/enetx/iter v0.0.0-20250912135656-f1583323588f // indirect + github.com/enetx/utls v0.0.0-20260115181616-c525a7d559c8 // indirect + github.com/golang/protobuf v1.2.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/wzshiming/socks5 v0.7.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/appengine v1.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/v2/go.sum b/v2/go.sum new file mode 100644 index 0000000..df78e0c --- /dev/null +++ b/v2/go.sum @@ -0,0 +1,69 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/enetx/g v1.0.202 h1:E32/PWp542wHh7c5VYPGohK0Nu6CpiRm34vr0P1F6dU= +github.com/enetx/g v1.0.202/go.mod h1:6/HQeRy+tIJVGY+oRPQVJ/vOSruAi0aldFggurT6jBY= +github.com/enetx/http v1.0.24 h1:sMeJ0rN00HlUYLIhUTHx36KumAlkaZu7vsQe5+GMTNM= +github.com/enetx/http v1.0.24/go.mod h1:1f4mytfF/SfjATEJnynpwGS6aa1ALjb8DtmYgFVblY0= +github.com/enetx/http2 v1.0.24 h1:MpNGZxD8wz2zg2bR4RSyB/4YJukZ91wIsQE2GLwQjCI= +github.com/enetx/http2 v1.0.24/go.mod h1:t54ex5HIS8V1+2j6cvEOv6umlrHsbUPFKQ54nYB58Nk= +github.com/enetx/http3 v1.0.6 h1:zOmCIOHqImGz0U3QTjvxyWIcPq7d4iTQ3sKZYejax8g= +github.com/enetx/http3 v1.0.6/go.mod h1:sqpVGZ9F1/wCiW6sjBUS2errKAh3SUYn6VlWE7LL6KM= +github.com/enetx/iter v0.0.0-20250912135656-f1583323588f h1:GUW+4AWfECIEJ9oAxgEAVGCpaozMCjRiUYnuR6Q0bCQ= +github.com/enetx/iter v0.0.0-20250912135656-f1583323588f/go.mod h1:oMZN8hGLUpi7QBlMEUqailocNy0NFAO/7Lu+Nwh9HMM= +github.com/enetx/surf v1.0.173 h1:36U3d5bGzncQJE8iqNAahIt6SlUYBYPJ90uigf7hqCw= +github.com/enetx/surf v1.0.173/go.mod h1:7kQlsgHH2OT69AB/9w9/9Nppx3wijRFOAPnH4IhwMBM= +github.com/enetx/utls v0.0.0-20260115181616-c525a7d559c8 h1:jN2LdG4CG7cXOMAkQVDencj67Gdt7FFwDP1UaPcYyV8= +github.com/enetx/utls v0.0.0-20260115181616-c525a7d559c8/go.mod h1:jsHaW4RX6DteSbAHT/pW7iJxv7YXLf2NTr3k9PsmXoc= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wzshiming/socks5 v0.7.0 h1:euJ+U48WrvVngi+opC8vAnpZ5sK12y1C2hPvb1f48Rg= +github.com/wzshiming/socks5 v0.7.0/go.mod h1:BvCAqlzocQN5xwLjBZDBbvWlrx8sCYSSbHEOf2wZgT0= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/v2/reddit/account.go b/v2/reddit/account.go new file mode 100644 index 0000000..a4ed04d --- /dev/null +++ b/v2/reddit/account.go @@ -0,0 +1,400 @@ +package reddit + +import ( + "context" + "net/http" + "net/url" +) + +// AccountService handles communication with the account +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_account +type AccountService struct { + client *Client +} + +// SubredditKarma holds user karma data for the subreddit. +type SubredditKarma struct { + Subreddit string `json:"sr"` + PostKarma int `json:"link_karma"` + CommentKarma int `json:"comment_karma"` +} + +// Settings are the user's account settings. +// Some of the fields' descriptions are taken from: +// https://praw.readthedocs.io/en/latest/code_overview/other/preferences.html#praw.models.Preferences.update +type Settings struct { + // Control whose private messages you see. + // - "everyone": everyone except blocked users + // - "whitelisted": only trusted users + AcceptPrivateMessages *string `json:"accept_pms,omitempty"` + // Allow Reddit to use your activity on Reddit to show you more relevant advertisements. + ActivityRelevantAds *bool `json:"activity_relevant_ads,omitempty"` + // Allow reddit to log my outbound clicks for personalization. + AllowClickTracking *bool `json:"allow_clicktracking,omitempty"` + + // Beta test features for reddit. By enabling, you will join r/beta immediately. + Beta *bool `json:"beta,omitempty"` + // Show me links I've recently viewed. + ShowRecentlyViewedPosts *bool `json:"clickgadget,omitempty"` + + CollapseReadMessages *bool `json:"collapse_read_messages,omitempty"` + + // Compress the post display (make them look more compact). + Compress *bool `json:"compress,omitempty"` + + CredditAutorenew *bool `json:"creddit_autorenew,omitempty"` + + // One of "confidence", "top", "new", "controversial", "old", "random", "qa", "live". + DefaultCommentSort *string `json:"default_comment_sort,omitempty"` + + // Show additional details in the domain text when available, + // such as the source subreddit or the content author’s url/name. + ShowDomainDetails *bool `json:"domain_details,omitempty"` + + SendEmailDigests *bool `json:"email_digests,omitempty"` + SendMessagesAsEmails *bool `json:"email_messages,omitempty"` + UnsubscribeFromAllEmails *bool `json:"email_unsubscribe_all,omitempty"` + + // Disable subreddits from displaying their custom themes. + DisableCustomThemes *bool `json:"enable_default_themes,omitempty"` + + // One of "GLOBAL", "AR", "AU", "BG", "CA", "CL", "CO", "CZ", "FI", "GB", "GR", "HR", "HU", + // "IE", "IN", "IS", "JP", "MX", "MY", "NZ", "PH", "PL", "PR", "PT", "RO", "RS", "SE", "SG", + // "TH", "TR", "TW", "US", "US_AK", "US_AL", "US_AR", "US_AZ", "US_CA", "US_CO", "US_CT", + // "US_DC", "US_DE", "US_FL", "US_GA", "US_HI", "US_IA", "US_ID", "US_IL", "US_IN", "US_KS", + // "US_KY", "US_LA", "US_MA", "US_MD", "US_ME", "US_MI", "US_MN", "US_MO", "US_MS", "US_MT", + // "US_NC", "US_ND", "US_NE", "US_NH", "US_NJ", "US_NM", "US_NV", "US_NY", "US_OH", "US_OK", + // "US_OR", "US_PA", "US_RI", "US_SC", "US_SD", "US_TN", "US_TX", "US_UT", "US_VA", "US_VT", + // "US_WA", "US_WI", "US_WV", "US_WY". + Location *string `json:"geopopular,omitempty"` + + HideAds *bool `json:"hide_ads,omitempty"` + + // Don't allow search engines to index my user profile. + HideFromSearchEngines *bool `json:"hide_from_robots,omitempty"` + + // Don’t show me posts after I’ve upvoted them, except my own. + HideUpvotedPosts *bool `json:"hide_ups,omitempty"` + // Don’t show me posts after I’ve downvoted them, except my own. + HideDownvotedPosts *bool `json:"hide_downs,omitempty"` + + // Show a dagger (†) on comments voted controversial (one that's been + // upvoted and downvoted significantly). + HighlightControversialComments *bool `json:"highlight_controversial,omitempty"` + HighlightNewComments *bool `json:"highlight_new_comments,omitempty"` + + // Ignore suggested sorts for specific threads/subreddits, like Q&As. + IgnoreSuggestedSorts *bool `json:"ignore_suggested_sort,omitempty"` + + // Use new Reddit as my default experience. + // Use this to SET the setting. + UseNewReddit *bool `json:"in_redesign_beta,omitempty"` + + // Use new Reddit as my default experience. + // Use this to GET the setting. + UsesNewReddit *bool `json:"design_beta,omitempty"` + + // Label posts that are not safe for work (NSFW). + LabelNSFW *bool `json:"label_nsfw,omitempty"` + + // A valid IETF language tag (underscore separated). + Language *string `json:"lang,omitempty"` + + ShowOldSearchPage *bool `json:"legacy_search,omitempty"` + + // Send message notifications in my browser. + EnableNotifications *bool `json:"live_orangereds,omitempty"` + + MarkMessagesAsRead *bool `json:"mark_messages_read,omitempty"` + + // Determine whether to show thumbnails next to posts in subreddits. + // - "on": show thumbnails next to posts + // - "off": do not show thumbnails next to posts + // - "subreddit": show thumbnails next to posts based on the subreddit's preferences + ShowThumbnails *string `json:"media,omitempty"` + + // Determine whether to auto-expand media in subreddits. + // - "on": auto-expand media previews + // - "off": do not auto-expand media previews + // - "subreddit": auto-expand media previews based on the subreddit's preferences + AutoExpandMedia *string `json:"media_preview,omitempty"` + + // Don't show me comments with a score less than this number. + // Must be between -100 and 100 (inclusive). + MinimumCommentScore *int `json:"min_comment_score,omitempty"` + + // Don't show me posts with a score less than this number. + // Must be between -100 and 100 (inclusive). + MinimumPostScore *int `json:"min_link_score,omitempty"` + + // Notify me when people say my username. + EnableMentionNotifications *bool `json:"monitor_mentions,omitempty"` + + // Opens link in a new window/tab. + OpenLinksInNewWindow *bool `json:"newwindow,omitempty"` + + DarkMode *bool `json:"nightmode,omitempty"` + DisableProfanity *bool `json:"no_profanity,omitempty"` + + // Display this many comments by default. + // Must be between 1 and 500 (inclusive). + NumberOfComments *int `json:"num_comments,omitempty,omitempty"` + + // Display this many posts by default. + // Must be between 1 and 100 (inclusive). + NumberOfPosts *int `json:"numsites,omitempty,omitempty"` + + // Show the spotlight box on the home feed. + // Not sure what this is though... + ShowSpotlightBox *bool `json:"organic,omitempty"` + + SubredditTheme *string `json:"other_theme,omitempty"` + + // Show content that is labeled not safe for work (NSFW). + ShowNSFW *bool `json:"over_18,omitempty"` + + EnablePrivateRSSFeeds *bool `json:"private_feeds,omitempty"` + + // View user profiles on desktop using legacy mode. + ProfileOptOut *bool `json:"profile_opt_out,omitempty"` + // Make my upvotes and downvotes public. + PublicizeVotes *bool `json:"public_votes,omitempty"` + + // Allow my data to be used for research purposes. + AllowResearch *bool `json:"research,omitempty"` + + IncludeNSFWSearchResults *bool `json:"search_include_over_18,omitempty"` + + // Receive a message when my post gets cross-posted. + ReceiveCrosspostMessages *bool `json:"send_crosspost_messages,omitempty"` + // Receive welcome messages from moderators when I join a community. + ReceiveWelcomeMessages *bool `json:"send_welcome_messages,omitempty"` + + // Show a user's flair (next to their name on a post or comment). + ShowUserFlair *bool `json:"show_flair,omitempty"` + // Show a post's flair. + ShowPostFlair *bool `json:"show_link_flair,omitempty"` + + // Show how much gold you have remaining on your profile. + ShowGoldExpiration *bool `json:"show_gold_expiration,omitempty"` + ShowLocationBasedRecommendations *bool `json:"show_location_based_recommendations,omitempty"` + ShowPromote *bool `json:"show_promote,omitempty"` + ShowCustomSubredditThemes *bool `json:"show_stylesheets,omitempty"` + + // Show trending subreddits on the home feed. + ShowTrendingSubreddits *bool `json:"show_trending,omitempty"` + ShowTwitter *bool `json:"show_twitter,omitempty"` + + // Store whether or not you want to track posts you've visited. + StoreVisits *bool `json:"store_visits,omitempty"` + ThemeSelector *string `json:"theme_selector,omitempty"` + + // Allow Reddit to use data provided by third-parties to show you more relevant advertisements on Reddit.i + AllowThirdPartyDataAdPersonalization *bool `json:"third_party_data_personalized_ads,omitempty"` + // Allow personalization of advertisements using data from third-party websites. + AllowThirdPartySiteDataAdPersonalization *bool `json:"third_party_site_data_personalized_ads,omitempty"` + // Allow personalization of content using data from third-party websites. + AllowThirdPartySiteDataContentPersonalization *bool `json:"third_party_site_data_personalized_content,omitempty"` + + EnableThreadedMessages *bool `json:"threaded_messages,omitempty"` + EnableThreadedModmail *bool `json:"threaded_modmail,omitempty"` + + // Show the communities you are active in on your profile (mobile only). + TopKarmaSubreddits *bool `json:"top_karma_subreddits,omitempty"` + + UseGlobalDefaults *bool `json:"use_global_defaults,omitempty"` + EnableVideoAutoplay *bool `json:"video_autoplay,omitempty"` +} + +type rootRelationshipList struct { + Kind string `json:"kind,omitempty"` + Data struct { + Relationships []Relationship `json:"children"` + } `json:"data"` +} + +// Info returns some general information about your account. +func (s *AccountService) Info(ctx context.Context) (*User, *Response, error) { + path := "api/v1/me" + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(User) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Karma returns a breakdown of your karma per subreddit. +func (s *AccountService) Karma(ctx context.Context) ([]*SubredditKarma, *Response, error) { + path := "api/v1/me/karma" + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + karma, _ := t.Karma() + return karma, resp, nil +} + +// Settings returns your account settings. +func (s *AccountService) Settings(ctx context.Context) (*Settings, *Response, error) { + path := "api/v1/me/prefs" + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(Settings) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// UpdateSettings updates your account settings and returns the modified version. +func (s *AccountService) UpdateSettings(ctx context.Context, settings *Settings) (*Settings, *Response, error) { + path := "api/v1/me/prefs" + + req, err := s.client.NewJSONRequest(http.MethodPatch, path, settings) + if err != nil { + return nil, nil, err + } + + root := new(Settings) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Trophies returns a list of your trophies. +func (s *AccountService) Trophies(ctx context.Context) ([]*Trophy, *Response, error) { + path := "api/v1/me/trophies" + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + trophies, _ := t.TrophyList() + return trophies, resp, nil +} + +// Friends returns a list of your friends. +func (s *AccountService) Friends(ctx context.Context) ([]Relationship, *Response, error) { + path := "prefs/friends" + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + var root [2]rootRelationshipList + resp, err := s.client.Do(ctx, req, &root) + if err != nil { + return nil, resp, err + } + + return root[0].Data.Relationships, resp, nil +} + +// Blocked returns a list of your blocked users. +func (s *AccountService) Blocked(ctx context.Context) ([]Relationship, *Response, error) { + path := "prefs/blocked" + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(rootRelationshipList) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Data.Relationships, resp, nil +} + +// Messaging returns blocked users and trusted users, respectively. +func (s *AccountService) Messaging(ctx context.Context) ([]Relationship, []Relationship, *Response, error) { + path := "prefs/messaging" + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, nil, err + } + + var root [2]rootRelationshipList + resp, err := s.client.Do(ctx, req, &root) + if err != nil { + return nil, nil, resp, err + } + + blocked := root[0].Data.Relationships + trusted := root[1].Data.Relationships + + return blocked, trusted, resp, nil +} + +// Trusted returns a list of your trusted users. +func (s *AccountService) Trusted(ctx context.Context) ([]Relationship, *Response, error) { + path := "prefs/trusted" + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(rootRelationshipList) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Data.Relationships, resp, nil +} + +// AddTrusted adds a user to your trusted users. +// This is not visible in the Reddit API docs. +func (s *AccountService) AddTrusted(ctx context.Context, username string) (*Response, error) { + path := "api/add_whitelisted" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// RemoveTrusted removes a user from your trusted users. +// This is not visible in the Reddit API docs. +func (s *AccountService) RemoveTrusted(ctx context.Context, username string) (*Response, error) { + path := "api/remove_whitelisted" + + form := url.Values{} + form.Set("name", username) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/account_test.go b/v2/reddit/account_test.go new file mode 100644 index 0000000..a555210 --- /dev/null +++ b/v2/reddit/account_test.go @@ -0,0 +1,313 @@ +package reddit + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedInfo = &User{ + ID: "164ab8", + Name: "v_95", + Created: &Timestamp{time.Date(2017, 3, 12, 4, 56, 47, 0, time.UTC)}, + PostKarma: 488, + CommentKarma: 22223, + HasVerifiedEmail: true, + NSFW: true, +} + +var expectedKarma = []*SubredditKarma{ + {Subreddit: "nba", PostKarma: 144, CommentKarma: 21999}, + {Subreddit: "redditdev", PostKarma: 19, CommentKarma: 4}, + {Subreddit: "test", PostKarma: 1, CommentKarma: 0}, + {Subreddit: "golang", PostKarma: 1, CommentKarma: 0}, +} + +var expectedSettings = &Settings{ + AcceptPrivateMessages: String("everyone"), + ActivityRelevantAds: Bool(false), + AllowClickTracking: Bool(false), + Beta: Bool(false), + ShowRecentlyViewedPosts: Bool(true), + CollapseReadMessages: Bool(false), + Compress: Bool(false), + CredditAutorenew: nil, + DefaultCommentSort: String("top"), + ShowDomainDetails: Bool(false), + SendEmailDigests: Bool(false), + SendMessagesAsEmails: Bool(false), + UnsubscribeFromAllEmails: Bool(true), + DisableCustomThemes: Bool(false), + Location: String("GLOBAL"), + HideAds: Bool(false), + HideFromSearchEngines: Bool(false), + HideUpvotedPosts: Bool(false), + HideDownvotedPosts: Bool(false), + HighlightControversialComments: Bool(true), + HighlightNewComments: Bool(true), + IgnoreSuggestedSorts: Bool(true), + UseNewReddit: nil, + UsesNewReddit: Bool(false), + LabelNSFW: Bool(true), + Language: String("en-ca"), + ShowOldSearchPage: Bool(false), + EnableNotifications: Bool(true), + MarkMessagesAsRead: Bool(true), + ShowThumbnails: String("subreddit"), + AutoExpandMedia: String("off"), + MinimumCommentScore: nil, + MinimumPostScore: nil, + EnableMentionNotifications: Bool(true), + OpenLinksInNewWindow: Bool(true), + DarkMode: Bool(true), + DisableProfanity: Bool(false), + NumberOfComments: Int(200), + NumberOfPosts: Int(25), + ShowSpotlightBox: nil, + SubredditTheme: nil, + ShowNSFW: Bool(true), + EnablePrivateRSSFeeds: Bool(true), + ProfileOptOut: Bool(false), + PublicizeVotes: Bool(false), + AllowResearch: Bool(false), + IncludeNSFWSearchResults: Bool(true), + ReceiveCrosspostMessages: Bool(false), + ReceiveWelcomeMessages: Bool(true), + ShowUserFlair: Bool(true), + ShowPostFlair: Bool(true), + ShowGoldExpiration: Bool(false), + ShowLocationBasedRecommendations: Bool(false), + ShowPromote: nil, + ShowCustomSubredditThemes: Bool(true), + ShowTrendingSubreddits: Bool(true), + ShowTwitter: Bool(false), + StoreVisits: Bool(false), + ThemeSelector: nil, + AllowThirdPartyDataAdPersonalization: Bool(false), + AllowThirdPartySiteDataAdPersonalization: Bool(false), + AllowThirdPartySiteDataContentPersonalization: Bool(false), + EnableThreadedMessages: Bool(true), + EnableThreadedModmail: Bool(false), + TopKarmaSubreddits: Bool(false), + UseGlobalDefaults: Bool(false), + EnableVideoAutoplay: Bool(true), +} + +var expectedRelationships = []Relationship{ + { + ID: "r9_1r4879", + User: "test1", + UserID: "t2_test1", + Created: &Timestamp{time.Date(2020, 6, 28, 16, 43, 55, 0, time.UTC)}, + }, + { + ID: "r9_1re930", + User: "test2", + UserID: "t2_test2", + Created: &Timestamp{time.Date(2020, 6, 28, 16, 44, 2, 0, time.UTC)}, + }, +} + +var expectedRelationships2 = []Relationship{ + { + ID: "r9_1re60i", + User: "test3", + UserID: "t2_test3", + Created: &Timestamp{time.Date(2020, 3, 6, 2, 27, 0, 0, time.UTC)}, + }, +} + +func TestAccountService_Info(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/account/info.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/me", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + info, _, err := client.Account.Info(ctx) + require.NoError(t, err) + require.Equal(t, expectedInfo, info) +} + +func TestAccountService_Karma(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/account/karma.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/me/karma", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + karma, _, err := client.Account.Karma(ctx) + require.NoError(t, err) + require.Equal(t, expectedKarma, karma) +} + +func TestAccountService_Settings(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/account/settings.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/me/prefs", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + settings, _, err := client.Account.Settings(ctx) + require.NoError(t, err) + require.Equal(t, expectedSettings, settings) +} + +func TestAccountService_UpdateSettings(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/account/settings.json") + require.NoError(t, err) + + expectedSettingsBody := &Settings{NumberOfPosts: Int(10), MinimumCommentScore: Int(5), Compress: Bool(true)} + + mux.HandleFunc("/api/v1/me/prefs", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + + settingsBody := new(Settings) + err := json.NewDecoder(r.Body).Decode(settingsBody) + require.NoError(t, err) + require.Equal(t, expectedSettingsBody, settingsBody) + + fmt.Fprint(w, blob) + }) + + settings, _, err := client.Account.UpdateSettings(ctx, expectedSettingsBody) + require.NoError(t, err) + require.Equal(t, expectedSettings, settings) +} + +func TestAccountService_Trophies(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/account/trophies.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/me/trophies", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + trophies, _, err := client.Account.Trophies(ctx) + require.NoError(t, err) + require.Equal(t, expectedTrophies, trophies) +} + +func TestAccountService_Friends(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/account/friends.json") + require.NoError(t, err) + + mux.HandleFunc("/prefs/friends", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + relationships, _, err := client.Account.Friends(ctx) + require.NoError(t, err) + require.Equal(t, expectedRelationships, relationships) +} + +func TestAccountService_Blocked(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/account/blocked.json") + require.NoError(t, err) + + mux.HandleFunc("/prefs/blocked", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + relationships, _, err := client.Account.Blocked(ctx) + require.NoError(t, err) + require.Equal(t, expectedRelationships, relationships) +} + +func TestAccountService_Messaging(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/account/messaging.json") + require.NoError(t, err) + + mux.HandleFunc("/prefs/messaging", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + blocked, trusted, _, err := client.Account.Messaging(ctx) + require.NoError(t, err) + require.Equal(t, expectedRelationships, blocked) + require.Equal(t, expectedRelationships2, trusted) +} + +func TestAccountService_Trusted(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/account/trusted.json") + require.NoError(t, err) + + mux.HandleFunc("/prefs/trusted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + relationships, _, err := client.Account.Trusted(ctx) + require.NoError(t, err) + require.Equal(t, expectedRelationships, relationships) +} + +func TestAccountService_AddTrusted(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/add_whitelisted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "test123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Account.AddTrusted(ctx, "test123") + require.NoError(t, err) +} + +func TestAccountService_RemoveTrusted(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/remove_whitelisted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("name", "test123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Account.RemoveTrusted(ctx, "test123") + require.NoError(t, err) +} diff --git a/v2/reddit/collection.go b/v2/reddit/collection.go new file mode 100644 index 0000000..d05087e --- /dev/null +++ b/v2/reddit/collection.go @@ -0,0 +1,290 @@ +package reddit + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/google/go-querystring/query" +) + +// CollectionService handles communication with the collection +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_collections +type CollectionService struct { + client *Client +} + +// Collection is a mod curated group of posts within a subreddit. +type Collection struct { + ID string `json:"collection_id,omitempty"` + Created *Timestamp `json:"created_at_utc,omitempty"` + Updated *Timestamp `json:"last_update_utc,omitempty"` + + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Permalink string `json:"permalink,omitempty"` + Layout string `json:"display_layout,omitempty"` + + SubredditID string `json:"subreddit_id,omitempty"` + Author string `json:"author_name,omitempty"` + AuthorID string `json:"author_id,omitempty"` + + // Post at the top of the collection. + // This does not appear when getting a list of collections. + PrimaryPostID string `json:"primary_link_id,omitempty"` + PostIDs []string `json:"link_ids,omitempty"` +} + +// CollectionCreateRequest represents a request to create a collection. +type CollectionCreateRequest struct { + Title string `url:"title"` + Description string `url:"description,omitempty"` + SubredditID string `url:"sr_fullname"` + // One of: TIMELINE, GALLERY. + Layout string `url:"display_layout,omitempty"` +} + +// Get gets a collection by its ID. +func (s *CollectionService) Get(ctx context.Context, id string) (*Collection, *Response, error) { + path := "api/v1/collections/collection" + + params := struct { + ID string `url:"collection_id"` + IncludePosts bool `url:"include_links"` + }{id, false} + + path, err := addOptions(path, params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + collection := new(Collection) + resp, err := s.client.Do(ctx, req, collection) + if err != nil { + return nil, resp, err + } + + return collection, resp, nil +} + +// FromSubreddit gets all collections in the subreddit. +func (s *CollectionService) FromSubreddit(ctx context.Context, id string) ([]*Collection, *Response, error) { + path := "api/v1/collections/subreddit_collections" + + params := struct { + SubredditID string `url:"sr_fullname"` + }{id} + + path, err := addOptions(path, params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + var collections []*Collection + resp, err := s.client.Do(ctx, req, &collections) + if err != nil { + return nil, resp, err + } + + return collections, resp, nil +} + +// Create a collection. +func (s *CollectionService) Create(ctx context.Context, createRequest *CollectionCreateRequest) (*Collection, *Response, error) { + if createRequest == nil { + return nil, nil, errors.New("*CollectionCreateRequest: cannot be nil") + } + + path := "api/v1/collections/create_collection" + + form, err := query.Values(createRequest) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + collection := new(Collection) + resp, err := s.client.Do(ctx, req, collection) + if err != nil { + return nil, resp, err + } + + return collection, resp, nil +} + +// Delete a collection via its id. +func (s *CollectionService) Delete(ctx context.Context, id string) (*Response, error) { + path := "api/v1/collections/delete_collection" + + form := url.Values{} + form.Set("collection_id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// AddPost adds a post (via its full ID) to a collection (via its id). +func (s *CollectionService) AddPost(ctx context.Context, postID, collectionID string) (*Response, error) { + path := "api/v1/collections/add_post_to_collection" + + form := url.Values{} + form.Set("link_fullname", postID) + form.Set("collection_id", collectionID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// RemovePost removes a post (via its full ID) from a collection (via its id). +func (s *CollectionService) RemovePost(ctx context.Context, postID, collectionID string) (*Response, error) { + path := "api/v1/collections/remove_post_in_collection" + + form := url.Values{} + form.Set("link_fullname", postID) + form.Set("collection_id", collectionID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// ReorderPosts reorders posts in a collection. +func (s *CollectionService) ReorderPosts(ctx context.Context, collectionID string, postIDs ...string) (*Response, error) { + path := "api/v1/collections/reorder_collection" + + form := url.Values{} + form.Set("collection_id", collectionID) + form.Set("link_ids", strings.Join(postIDs, ",")) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UpdateTitle updates a collection's title. +func (s *CollectionService) UpdateTitle(ctx context.Context, id string, title string) (*Response, error) { + path := "api/v1/collections/update_collection_title" + + form := url.Values{} + form.Set("collection_id", id) + form.Set("title", title) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UpdateDescription updates a collection's description. +func (s *CollectionService) UpdateDescription(ctx context.Context, id string, description string) (*Response, error) { + path := "api/v1/collections/update_collection_description" + + form := url.Values{} + form.Set("collection_id", id) + form.Set("description", description) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UpdateLayoutTimeline updates a collection's layout to the timeline format. +func (s *CollectionService) UpdateLayoutTimeline(ctx context.Context, id string) (*Response, error) { + path := "api/v1/collections/update_collection_display_layout" + + form := url.Values{} + form.Set("collection_id", id) + form.Set("display_layout", "TIMELINE") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UpdateLayoutGallery updates a collection's layout to the gallery format. +func (s *CollectionService) UpdateLayoutGallery(ctx context.Context, id string) (*Response, error) { + path := "api/v1/collections/update_collection_display_layout" + + form := url.Values{} + form.Set("collection_id", id) + form.Set("display_layout", "GALLERY") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Follow a collection. +func (s *CollectionService) Follow(ctx context.Context, id string) (*Response, error) { + path := "api/v1/collections/follow_collection" + + form := url.Values{} + form.Set("collection_id", id) + form.Set("follow", "true") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Unfollow a collection. +func (s *CollectionService) Unfollow(ctx context.Context, id string) (*Response, error) { + path := "api/v1/collections/follow_collection" + + form := url.Values{} + form.Set("collection_id", id) + form.Set("follow", "false") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/collection_test.go b/v2/reddit/collection_test.go new file mode 100644 index 0000000..f75acc6 --- /dev/null +++ b/v2/reddit/collection_test.go @@ -0,0 +1,342 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedCollection = &Collection{ + ID: "37f1e52d-7ec9-466b-b4cc-59e86e071ed7", + Created: &Timestamp{time.Date(2020, 8, 6, 23, 25, 3, 0, time.UTC)}, + Updated: &Timestamp{time.Date(2020, 8, 7, 1, 59, 32, 0, time.UTC)}, + + Title: "Test Title", + Permalink: "https://www.reddit.com/r/helloworldtestt/collection/37f1e52d-7ec9-466b-b4cc-59e86e071ed7", + Layout: "TIMELINE", + + SubredditID: "t5_2uquw1", + Author: "v_95", + AuthorID: "t2_164ab8", + PrimaryPostID: "t3_hs0cyh", + PostIDs: []string{"t3_hs0cyh", "t3_hqrg8s", "t3_hs03f3"}, +} + +var expectedCollections = []*Collection{ + { + ID: "37f1e52d-7ec9-466b-b4cc-59e86e071ed7", + Created: &Timestamp{time.Date(2020, 8, 6, 23, 25, 3, 0, time.UTC)}, + Updated: &Timestamp{time.Date(2020, 8, 7, 1, 59, 32, 0, time.UTC)}, + + Title: "Test Title", + Permalink: "https://www.reddit.com/r/helloworldtestt/collection/37f1e52d-7ec9-466b-b4cc-59e86e071ed7", + Layout: "TIMELINE", + + SubredditID: "t5_2uquw1", + Author: "v_95", + AuthorID: "t2_164ab8", + PostIDs: []string{"t3_hs0cyh", "t3_hqrg8s", "t3_hs03f3"}, + }, + { + ID: "8e94db00-6605-46c6-b0d2-44653d6f538c", + Created: &Timestamp{time.Date(2020, 8, 7, 0, 56, 29, 0, time.UTC)}, + Updated: &Timestamp{time.Date(2020, 8, 7, 1, 59, 27, 0, time.UTC)}, + + Title: "Test Title 2", + Description: "Test Description", + Permalink: "https://www.reddit.com/r/helloworldtestt/collection/8e94db00-6605-46c6-b0d2-44653d6f538c", + + SubredditID: "t5_2uquw1", + Author: "v_95", + AuthorID: "t2_164ab8", + PostIDs: []string{}, + }, + { + ID: "a1b3e088-f6b8-4d98-9e93-adaacef113cd", + Created: &Timestamp{time.Date(2020, 8, 7, 0, 55, 24, 0, time.UTC)}, + Updated: &Timestamp{time.Date(2020, 8, 7, 0, 55, 24, 0, time.UTC)}, + + Title: "Test Title 3", + Permalink: "https://www.reddit.com/r/helloworldtestt/collection/a1b3e088-f6b8-4d98-9e93-adaacef113cd", + + SubredditID: "t5_2uquw1", + Author: "v_95", + AuthorID: "t2_164ab8", + PostIDs: []string{}, + }, +} + +func TestCollectionService_Get(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/collection/collection.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/collections/collection", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + form.Set("include_links", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + collection, _, err := client.Collection.Get(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + require.NoError(t, err) + require.Equal(t, expectedCollection, collection) +} + +func TestCollectionService_FromSubreddit(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/collection/collections.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/collections/subreddit_collections", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("sr_fullname", "t5_2uquw1") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + collections, _, err := client.Collection.FromSubreddit(ctx, "t5_2uquw1") + require.NoError(t, err) + require.Equal(t, expectedCollections, collections) +} + +func TestCollectionService_Create(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/collection/collection.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/collections/create_collection", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("title", "Test Title") + form.Set("sr_fullname", "t5_2uquw1") + form.Set("display_layout", "TIMELINE") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.Collection.Create(ctx, nil) + require.EqualError(t, err, "*CollectionCreateRequest: cannot be nil") + + collection, _, err := client.Collection.Create(ctx, &CollectionCreateRequest{ + Title: "Test Title", + SubredditID: "t5_2uquw1", + Layout: "TIMELINE", + }) + require.NoError(t, err) + require.Equal(t, expectedCollection, collection) +} + +func TestCollectionService_Delete(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/delete_collection", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.Delete(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + require.NoError(t, err) +} + +func TestCollectionService_AddPost(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/add_post_to_collection", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("link_fullname", "t3_hs03f3") + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.AddPost(ctx, "t3_hs03f3", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + require.NoError(t, err) +} + +func TestCollectionService_RemovePost(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/remove_post_in_collection", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("link_fullname", "t3_hs03f3") + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.RemovePost(ctx, "t3_hs03f3", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + require.NoError(t, err) +} + +func TestCollectionService_ReorderPosts(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/reorder_collection", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + form.Set("link_ids", "t3_hs0cyh,t3_hqrg8s,t3_hs03f3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.ReorderPosts(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7", "t3_hs0cyh", "t3_hqrg8s", "t3_hs03f3") + require.NoError(t, err) +} + +func TestCollectionService_UpdateTitle(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/update_collection_title", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + form.Set("title", "Test Title") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.UpdateTitle(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7", "Test Title") + require.NoError(t, err) +} + +func TestCollectionService_UpdateDescription(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/update_collection_description", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + form.Set("description", "Test Description") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.UpdateDescription(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7", "Test Description") + require.NoError(t, err) +} + +func TestCollectionService_UpdateLayoutTimeline(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/update_collection_display_layout", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + form.Set("display_layout", "TIMELINE") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.UpdateLayoutTimeline(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + require.NoError(t, err) +} + +func TestCollectionService_UpdateLayoutGallery(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/update_collection_display_layout", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + form.Set("display_layout", "GALLERY") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.UpdateLayoutGallery(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + require.NoError(t, err) +} + +func TestCollectionService_Follow(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/follow_collection", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + form.Set("follow", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.Follow(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + require.NoError(t, err) +} + +func TestCollectionService_Unfollow(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/collections/follow_collection", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("collection_id", "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + form.Set("follow", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Collection.Unfollow(ctx, "37f1e52d-7ec9-466b-b4cc-59e86e071ed7") + require.NoError(t, err) +} diff --git a/v2/reddit/comment.go b/v2/reddit/comment.go new file mode 100644 index 0000000..ca6389d --- /dev/null +++ b/v2/reddit/comment.go @@ -0,0 +1,122 @@ +package reddit + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" +) + +// CommentService handles communication with the comment +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_links_and_comments +type CommentService struct { + *postAndCommentService + client *Client +} + +// Submit a comment as a reply to a post, comment, or message. +// parentID is the full ID of the thing being replied to. +func (s *CommentService) Submit(ctx context.Context, parentID string, text string) (*Comment, *Response, error) { + path := "api/comment" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("parent", parentID) + form.Set("text", text) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(Comment) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Edit a comment. +func (s *CommentService) Edit(ctx context.Context, id string, text string) (*Comment, *Response, error) { + path := "api/editusertext" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("thing_id", id) + form.Set("text", text) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(Comment) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// LoadMoreReplies retrieves more replies that were left out when initially fetching the comment. +func (s *CommentService) LoadMoreReplies(ctx context.Context, comment *Comment) (*Response, error) { + if comment == nil { + return nil, errors.New("*Comment: cannot be nil") + } + + if !comment.HasMore() { + return nil, nil + } + + postID := comment.PostID + commentIDs := comment.Replies.More.Children + + form := url.Values{} + form.Set("api_type", "json") + form.Set("link_id", postID) + form.Set("children", strings.Join(commentIDs, ",")) + + path := "api/morechildren" + + // This was originally a GET, but with POST you can send a bigger payload + // since it's in the body and not the URI. + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + root := new(struct { + JSON struct { + Data struct { + Things things `json:"things"` + } `json:"data"` + } `json:"json"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return resp, err + } + + comments := root.JSON.Data.Things.Comments + mores := root.JSON.Data.Things.Mores + + for _, c := range comments { + comment.addCommentToReplies(c) + } + + if len(mores) > 0 { + comment.Replies.More = mores[0] + } else { + comment.Replies.More = nil + } + + return resp, nil +} diff --git a/v2/reddit/comment_test.go b/v2/reddit/comment_test.go new file mode 100644 index 0000000..e902550 --- /dev/null +++ b/v2/reddit/comment_test.go @@ -0,0 +1,355 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedCommentSubmitOrEdit = &Comment{ + ID: "test2", + FullID: "t1_test2", + ParentID: "t1_test", + Permalink: "/r/subreddit/comments/test1/some_thread/test2/", + + Body: "test comment", + Author: "reddit_username", + AuthorID: "t2_user1", + AuthorFlairText: "Flair", + AuthorFlairID: "024b2b66-05ca-11e1-96f4-12313d096aae", + + SubredditName: "subreddit", + SubredditNamePrefixed: "r/subreddit", + SubredditID: "t5_test", + + Likes: Bool(true), + + Score: 1, + Controversiality: 0, + + Created: &Timestamp{time.Date(2020, 4, 29, 0, 9, 47, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + PostID: "t3_link1", +} + +func TestCommentService_Submit(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/comment/submit-or-edit.json") + require.NoError(t, err) + + mux.HandleFunc("/api/comment", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("parent", "t1_test") + form.Set("text", "test comment") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + comment, _, err := client.Comment.Submit(ctx, "t1_test", "test comment") + require.NoError(t, err) + require.Equal(t, expectedCommentSubmitOrEdit, comment) +} + +func TestCommentService_Edit(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/comment/submit-or-edit.json") + require.NoError(t, err) + + mux.HandleFunc("/api/editusertext", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("thing_id", "t1_test") + form.Set("text", "test comment") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + comment, _, err := client.Comment.Edit(ctx, "t1_test", "test comment") + require.NoError(t, err) + require.Equal(t, expectedCommentSubmitOrEdit, comment) +} + +func TestCommentService_Delete(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/del", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.Delete(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_Save(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/save", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.Save(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_Unsave(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/unsave", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.Unsave(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_EnableReplies(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/sendreplies", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + form.Set("state", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.EnableReplies(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_DisableReplies(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/sendreplies", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + form.Set("state", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.DisableReplies(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_Lock(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/lock", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.Lock(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_Unlock(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/unlock", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.Unlock(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_Upvote(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + form.Set("dir", "1") + form.Set("rank", "10") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.Upvote(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_Downvote(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + form.Set("dir", "-1") + form.Set("rank", "10") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.Downvote(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_RemoveVote(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t1_test") + form.Set("dir", "0") + form.Set("rank", "10") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Comment.RemoveVote(ctx, "t1_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCommentService_LoadMoreReplies(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/comment/more.json") + require.NoError(t, err) + + mux.HandleFunc("/api/morechildren", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("link_id", "t3_123") + form.Set("children", "def,ghi,jkl") + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, err = client.Comment.LoadMoreReplies(ctx, nil) + require.EqualError(t, err, "*Comment: cannot be nil") + + resp, err := client.Comment.LoadMoreReplies(ctx, &Comment{}) + require.Nil(t, resp) + require.Nil(t, err) + + comment := &Comment{ + FullID: "t1_abc", + PostID: "t3_123", + Replies: Replies{ + More: &More{ + Children: []string{"def", "ghi", "jkl"}, + }, + }, + } + + _, err = client.Comment.LoadMoreReplies(ctx, comment) + require.Nil(t, err) + require.False(t, comment.HasMore()) + require.Len(t, comment.Replies.Comments, 2) + require.Len(t, comment.Replies.Comments[0].Replies.Comments, 1) +} + +func TestCommentService_Report(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/report", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("thing_id", "t1_test") + form.Set("reason", "test reason") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Comment.Report(ctx, "t1_test", "test reason") + require.NoError(t, err) +} diff --git a/v2/reddit/compression.go b/v2/reddit/compression.go new file mode 100644 index 0000000..c3781f6 --- /dev/null +++ b/v2/reddit/compression.go @@ -0,0 +1,26 @@ +package reddit + +import ( + "compress/gzip" + "compress/zlib" + "io" + "net/http" + "strings" + + "github.com/andybalholm/brotli" +) + +func ResponseReader(resp *http.Response) (io.ReadCloser, error) { + encoding := resp.Header.Get("Content-Encoding") + switch strings.ToLower(encoding) { + case "deflate": + return zlib.NewReader(resp.Body) + case "gzip": + return gzip.NewReader(resp.Body) + case "br": + return io.NopCloser(brotli.NewReader(resp.Body)), nil + case "zstd": + return zlib.NewReader(resp.Body) + } + return resp.Body, nil +} diff --git a/v2/reddit/emoji.go b/v2/reddit/emoji.go new file mode 100644 index 0000000..7c72690 --- /dev/null +++ b/v2/reddit/emoji.go @@ -0,0 +1,278 @@ +package reddit + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + "github.com/google/go-querystring/query" + "golang.org/x/net/context/ctxhttp" +) + +// EmojiService handles communication with the emoji +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_emoji +type EmojiService struct { + client *Client +} + +// Emoji is a graphic element you can include in a post flair or user flair. +type Emoji struct { + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + UserFlairAllowed bool `json:"user_flair_allowed,omitempty"` + PostFlairAllowed bool `json:"post_flair_allowed,omitempty"` + ModFlairOnly bool `json:"mod_flair_only,omitempty"` + // ID of the user who created this emoji. + CreatedBy string `json:"created_by,omitempty"` +} + +// EmojiCreateOrUpdateRequest represents a request to create/update an emoji. +type EmojiCreateOrUpdateRequest struct { + Name string `url:"name"` + UserFlairAllowed *bool `url:"user_flair_allowed,omitempty"` + PostFlairAllowed *bool `url:"post_flair_allowed,omitempty"` + ModFlairOnly *bool `url:"mod_flair_only,omitempty"` +} + +func (r *EmojiCreateOrUpdateRequest) validate() error { + if r == nil { + return errors.New("*EmojiCreateOrUpdateRequest: cannot be nil") + } + if r.Name == "" { + return errors.New("(*EmojiCreateOrUpdateRequest).Name: cannot be empty") + } + return nil +} + +type emojis []*Emoji + +func (e *emojis) UnmarshalJSON(data []byte) (err error) { + emojiMap := make(map[string]json.RawMessage) + err = json.Unmarshal(data, &emojiMap) + if err != nil { + return + } + + *e = make(emojis, 0, len(emojiMap)) + for emojiName, emojiValue := range emojiMap { + emoji := new(Emoji) + err = json.Unmarshal(emojiValue, emoji) + if err != nil { + return + } + emoji.Name = emojiName + *e = append(*e, emoji) + } + + return +} + +// Get the default set of Reddit emojis and those of the subreddit, respectively. +func (s *EmojiService) Get(ctx context.Context, subreddit string) ([]*Emoji, []*Emoji, *Response, error) { + path := fmt.Sprintf("api/v1/%s/emojis/all", subreddit) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, nil, err + } + + root := make(map[string]emojis) + resp, err := s.client.Do(ctx, req, &root) + if err != nil { + return nil, nil, resp, err + } + + defaultEmojis := root["snoomojis"] + var subredditEmojis []*Emoji + + for k, v := range root { + if strings.HasPrefix(k, kindSubreddit) { + subredditEmojis = v + break + } + } + + return defaultEmojis, subredditEmojis, resp, nil +} + +// Delete the emoji from the subreddit. +func (s *EmojiService) Delete(ctx context.Context, subreddit string, emoji string) (*Response, error) { + path := fmt.Sprintf("api/v1/%s/emoji/%s", subreddit, emoji) + req, err := s.client.NewRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +// SetSize sets the custom emoji size in the subreddit. +// Both height and width must be between 1 and 40 (inclusive). +func (s *EmojiService) SetSize(ctx context.Context, subreddit string, height, width int) (*Response, error) { + path := fmt.Sprintf("api/v1/%s/emoji_custom_size", subreddit) + + form := url.Values{} + form.Set("height", strconv.Itoa(height)) + form.Set("width", strconv.Itoa(width)) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// DisableCustomSize disables the custom emoji size in the subreddit. +func (s *EmojiService) DisableCustomSize(ctx context.Context, subreddit string) (*Response, error) { + path := fmt.Sprintf("api/v1/%s/emoji_custom_size", subreddit) + req, err := s.client.NewRequest(http.MethodPost, path, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +func (s *EmojiService) lease(ctx context.Context, subreddit, imagePath string) (string, map[string]string, *Response, error) { + path := fmt.Sprintf("api/v1/%s/emoji_asset_upload_s3.json", subreddit) + + form := url.Values{} + form.Set("filepath", imagePath) + form.Set("mimetype", "image/jpeg") + if strings.HasSuffix(strings.ToLower(imagePath), ".png") { + form.Set("mimetype", "image/png") + } + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return "", nil, nil, err + } + + var response struct { + S3UploadLease struct { + Action string `json:"action"` + Fields []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"fields"` + } `json:"s3UploadLease"` + } + + resp, err := s.client.Do(ctx, req, &response) + if err != nil { + return "", nil, resp, err + } + + uploadURL := fmt.Sprintf("http:%s", response.S3UploadLease.Action) + + fields := make(map[string]string) + for _, field := range response.S3UploadLease.Fields { + fields[field.Name] = field.Value + } + + return uploadURL, fields, resp, nil +} + +func (s *EmojiService) upload(ctx context.Context, subreddit string, createRequest *EmojiCreateOrUpdateRequest, awsKey string) (*Response, error) { + path := fmt.Sprintf("api/v1/%s/emoji.json", subreddit) + + form, err := query.Values(createRequest) + if err != nil { + return nil, err + } + form.Set("s3_key", awsKey) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Upload an emoji to the subreddit. +func (s *EmojiService) Upload(ctx context.Context, subreddit string, createRequest *EmojiCreateOrUpdateRequest, imagePath string) (*Response, error) { + err := createRequest.validate() + if err != nil { + return nil, err + } + + uploadURL, fields, resp, err := s.lease(ctx, subreddit, imagePath) + if err != nil { + return resp, err + } + + file, err := os.Open(imagePath) + if err != nil { + return nil, err + } + defer file.Close() + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + + // AWS ignores all fields in the request that come after the file field, so we need to set these before + // https://stackoverflow.com/questions/15234496/upload-directly-to-amazon-s3-using-ajax-returning-error-bucket-post-must-contai/15235866#15235866 + for k, v := range fields { + writer.WriteField(k, v) + } + + part, err := writer.CreateFormFile("file", file.Name()) + if err != nil { + return nil, err + } + + _, err = io.Copy(part, file) + if err != nil { + return nil, err + } + + err = writer.Close() + if err != nil { + return nil, err + } + + httpResponse, err := ctxhttp.Post(ctx, nil, uploadURL, writer.FormDataContentType(), body) + if err != nil { + return nil, err + } + + err = CheckResponse(httpResponse) + if err != nil { + return newResponse(httpResponse), err + } + + return s.upload(ctx, subreddit, createRequest, fields["key"]) +} + +// Update updates an emoji on the subreddit. +func (s *EmojiService) Update(ctx context.Context, subreddit string, updateRequest *EmojiCreateOrUpdateRequest) (*Response, error) { + err := updateRequest.validate() + if err != nil { + return nil, err + } + + path := fmt.Sprintf("api/v1/%s/emoji_permissions", subreddit) + + form, err := query.Values(updateRequest) + if err != nil { + return nil, err + } + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/emoji_test.go b/v2/reddit/emoji_test.go new file mode 100644 index 0000000..1edb9fe --- /dev/null +++ b/v2/reddit/emoji_test.go @@ -0,0 +1,227 @@ +package reddit + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +var expectedDefaultEmojis = []*Emoji{ + { + Name: "cake", + URL: "https://emoji.redditmedia.com/46kel8lf1guz_t5_3nqvj/cake", + UserFlairAllowed: true, + PostFlairAllowed: true, + ModFlairOnly: false, + CreatedBy: "t2_6zfp6ii", + }, + { + Name: "cat_blep", + URL: "https://emoji.redditmedia.com/p9sxc1zh1guz_t5_3nqvj/cat_blep", + UserFlairAllowed: true, + PostFlairAllowed: true, + ModFlairOnly: false, + CreatedBy: "t2_6zfp6ii", + }, +} + +var expectedSubredditEmojis = []*Emoji{ + { + Name: "TestEmoji", + URL: "https://emoji.redditmedia.com/fxe5a674hpf51_t5_2uquw1/TestEmoji", + UserFlairAllowed: true, + PostFlairAllowed: true, + ModFlairOnly: false, + CreatedBy: "t2_164ab8", + }, +} + +func TestEmojiService_Get(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/emoji/emojis.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/test/emojis/all", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + defaultEmojis, subredditEmojis, _, err := client.Emoji.Get(ctx, "test") + require.NoError(t, err) + require.Len(t, defaultEmojis, 2) + require.Contains(t, expectedDefaultEmojis, defaultEmojis[0]) + require.Contains(t, expectedDefaultEmojis, defaultEmojis[1]) + require.Equal(t, expectedSubredditEmojis, subredditEmojis) +} + +func TestEmojiService_Delete(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/testsubreddit/emoji/testemoji", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + }) + + _, err := client.Emoji.Delete(ctx, "testsubreddit", "testemoji") + require.NoError(t, err) +} + +func TestEmojiService_SetSize(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/testsubreddit/emoji_custom_size", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("height", "20") + form.Set("width", "20") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Emoji.SetSize(ctx, "testsubreddit", 20, 20) + require.NoError(t, err) +} + +func TestEmojiService_DisableCustomSize(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/testsubreddit/emoji_custom_size", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Emoji.DisableCustomSize(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestEmojiService_Upload(t *testing.T) { + client, mux := setup(t) + + uploadURL := client.BaseURL.Host + "/api/emoji_upload" + + blob, err := readFileContents("../testdata/emoji/lease.json") + require.NoError(t, err) + blob = fmt.Sprintf(blob, uploadURL) + + emojiFile, err := ioutil.TempFile("/tmp", "emoji*.png") + require.NoError(t, err) + defer func() { + emojiFile.Close() + os.Remove(emojiFile.Name()) + }() + + _, err = emojiFile.WriteString("this is a test") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/testsubreddit/emoji_asset_upload_s3.json", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("filepath", emojiFile.Name()) + form.Set("mimetype", "image/png") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + mux.HandleFunc("/api/emoji_upload", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + _, file, err := r.FormFile("file") + require.NoError(t, err) + + rdr, err := file.Open() + require.NoError(t, err) + + buf := new(bytes.Buffer) + _, err = io.Copy(buf, rdr) + require.NoError(t, err) + require.Equal(t, "this is a test", buf.String()) + + form := url.Values{} + form.Set("key", "t5_2uquw1/t2_164ab8/a94a8f45ccb199a61c4c0873d391e98c982fabd3") + form.Set("test name", "test value") + + // for some reason this has to come after the FormFile call + err = r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + mux.HandleFunc("/api/v1/testsubreddit/emoji.json", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("name", "testemoji") + form.Set("user_flair_allowed", "false") + form.Set("post_flair_allowed", "true") + form.Set("mod_flair_only", "true") + form.Set("s3_key", "t5_2uquw1/t2_164ab8/a94a8f45ccb199a61c4c0873d391e98c982fabd3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err = client.Emoji.Upload(ctx, "testsubreddit", nil, emojiFile.Name()) + require.EqualError(t, err, "*EmojiCreateOrUpdateRequest: cannot be nil") + + _, err = client.Emoji.Upload(ctx, "testsubreddit", &EmojiCreateOrUpdateRequest{Name: ""}, emojiFile.Name()) + require.EqualError(t, err, "(*EmojiCreateOrUpdateRequest).Name: cannot be empty") + + _, err = client.Emoji.Upload(ctx, "testsubreddit", &EmojiCreateOrUpdateRequest{ + Name: "testemoji", + UserFlairAllowed: Bool(false), + PostFlairAllowed: Bool(true), + ModFlairOnly: Bool(true), + }, emojiFile.Name()) + require.NoError(t, err) +} + +func TestEmojiService_Update(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/testsubreddit/emoji_permissions", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("name", "testemoji") + form.Set("post_flair_allowed", "false") + form.Set("mod_flair_only", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Emoji.Update(ctx, "testsubreddit", nil) + require.EqualError(t, err, "*EmojiCreateOrUpdateRequest: cannot be nil") + + _, err = client.Emoji.Update(ctx, "testsubreddit", &EmojiCreateOrUpdateRequest{Name: ""}) + require.EqualError(t, err, "(*EmojiCreateOrUpdateRequest).Name: cannot be empty") + + _, err = client.Emoji.Update(ctx, "testsubreddit", &EmojiCreateOrUpdateRequest{ + Name: "testemoji", + PostFlairAllowed: Bool(false), + ModFlairOnly: Bool(true), + }) + require.NoError(t, err) +} diff --git a/v2/reddit/errors.go b/v2/reddit/errors.go new file mode 100644 index 0000000..3b20418 --- /dev/null +++ b/v2/reddit/errors.go @@ -0,0 +1,105 @@ +package reddit + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// APIError is an error coming from Reddit. +type APIError struct { + Label string + Reason string + Field string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("field %q caused %s: %s", e.Field, e.Label, e.Reason) +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (e *APIError) UnmarshalJSON(data []byte) error { + var info [3]string + + err := json.Unmarshal(data, &info) + if err != nil { + return err + } + + e.Label = info[0] + e.Reason = info[1] + e.Field = info[2] + + return nil +} + +// JSONErrorResponse is an error response that sometimes gets returned with a 200 code. +type JSONErrorResponse struct { + // HTTP response that caused this error. + Response *http.Response `json:"-"` + + JSON struct { + Errors []APIError `json:"errors,omitempty"` + } `json:"json"` +} + +func (r *JSONErrorResponse) Error() string { + errorMessages := make([]string, len(r.JSON.Errors)) + for i, err := range r.JSON.Errors { + errorMessages[i] = err.Error() + } + + return fmt.Sprintf( + "%s %s: %d %s", + r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, strings.Join(errorMessages, ";"), + ) +} + +// An ErrorResponse reports the error caused by an API request +type ErrorResponse struct { + // HTTP response that caused this error + Response *http.Response `json:"-"` + + // Error message + Message string `json:"message"` +} + +func (r *ErrorResponse) Error() string { + return fmt.Sprintf( + "%s %s: %d %s", + r.Response.Request.Method, r.Response.Request.URL, r.Response.StatusCode, r.Message, + ) +} + +// RateLimitError occurs when the client is sending too many requests to Reddit in a given time frame. +type RateLimitError struct { + // Rate specifies the last known rate limit for the client + Rate Rate + // HTTP response that caused this error + Response *http.Response + // Error message + Message string +} + +func (e *RateLimitError) Error() string { + return fmt.Sprintf( + "%s %s: %d %s %s", + e.Response.Request.Method, e.Response.Request.URL, e.Response.StatusCode, e.Message, e.formateRateReset(), + ) +} + +func (e *RateLimitError) formateRateReset() string { + d := time.Until(e.Rate.Reset).Round(time.Second) + + isNegative := d < 0 + if isNegative { + d *= -1 + } + + if isNegative { + return fmt.Sprintf("[rate limit was reset %s ago]", d) + } + return fmt.Sprintf("[rate limit will reset in %s]", d) +} diff --git a/v2/reddit/flair.go b/v2/reddit/flair.go new file mode 100644 index 0000000..0ecc8e8 --- /dev/null +++ b/v2/reddit/flair.go @@ -0,0 +1,562 @@ +package reddit + +import ( + "bytes" + "context" + "encoding/csv" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/google/go-querystring/query" +) + +// FlairService handles communication with the flair +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_flair +type FlairService struct { + client *Client +} + +// Flair is a tag that can be attached to a user or a post. +type Flair struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + + Color string `json:"text_color,omitempty"` + BackgroundColor string `json:"background_color,omitempty"` + CSSClass string `json:"css_class,omitempty"` + + Editable bool `json:"text_editable"` + ModOnly bool `json:"mod_only"` +} + +// FlairSummary is a condensed version of Flair. +type FlairSummary struct { + User string `json:"user,omitempty"` + Text string `json:"flair_text,omitempty"` + CSSClass string `json:"flair_css_class,omitempty"` +} + +// FlairChoice is a choice of flair when selecting one for yourself or for a post. +type FlairChoice struct { + TemplateID string `json:"flair_template_id"` + Text string `json:"flair_text"` + Editable bool `json:"flair_text_editable"` + Position string `json:"flair_position"` + CSSClass string `json:"flair_css_class"` +} + +// FlairConfigureRequest represents a request to configure a subreddit's flair settings. +// Not setting an attribute can have unexpected side effects, so assign every one just in case. +type FlairConfigureRequest struct { + // Enable user flair in the subreddit. + UserFlairEnabled *bool `url:"flair_enabled,omitempty"` + // One of: left, right. + UserFlairPosition string `url:"flair_position,omitempty"` + // Allow users to assign their own flair. + UserFlairSelfAssignEnabled *bool `url:"flair_self_assign_enabled,omitempty"` + + // One of: none, left, right. + PostFlairPosition string `url:"link_flair_position,omitempty"` + // Allow submitters to assign their own post flair. + PostFlairSelfAssignEnabled *bool `url:"link_flair_self_assign_enabled,omitempty"` +} + +// FlairTemplateCreateOrUpdateRequest represents a request to create/update a flair template. +// Not setting an attribute can have unexpected side effects, so assign every one just in case. +type FlairTemplateCreateOrUpdateRequest struct { + // The id of the template. Only provide this if it's an update request. + // If provided and it's not a valid id, the template will be created. + ID string `url:"flair_template_id,omitempty"` + + // One of: all, emoji, text. + AllowableContent string `url:"allowable_content,omitempty"` + // No longer than 64 characters. + Text string `url:"text,omitempty"` + // One of: light, dark. + TextColor string `url:"text_color,omitempty"` + // Allow user to edit the text of the flair. + TextEditable *bool `url:"text_editable,omitempty"` + + ModOnly *bool `url:"mod_only,omitempty"` + + // Between 1 and 10 (inclusive). Default: 10. + MaxEmojis *int `url:"max_emojis,omitempty"` + + // One of: none, transparent, 6-digit rgb hex color, e.g. #AABBCC. + BackgroundColor string `url:"background_color,omitempty"` + CSSClass string `url:"css_class,omitempty"` +} + +// FlairTemplate is a generic flair structure that can users can use next to their username +// or posts in a subreddit. +type FlairTemplate struct { + ID string `json:"id"` + // USER_FLAIR (for users) or LINK_FLAIR (for posts). + Type string `json:"flairType"` + ModOnly bool `json:"modOnly"` + + AllowableContent string `json:"allowableContent"` + Text string `json:"text"` + TextType string `json:"type"` + TextColor string `json:"textColor"` + TextEditable bool `json:"textEditable"` + RichText []map[string]string `json:"richtext"` + + OverrideCSS bool `json:"overrideCss"` + MaxEmojis int `json:"maxEmojis"` + BackgroundColor string `json:"backgroundColor"` + CSSClass string `json:"cssClass"` +} + +// FlairSelectRequest represents a request to select a flair. +type FlairSelectRequest struct { + // The id of the template. + ID string `url:"flair_template_id,omitempty"` + // No longer than 64 characters. + // Only use this if the flair is editable (it is by default if you're a mod of the subreddit). + Text string `url:"text,omitempty"` +} + +// FlairChangeRequest represents a request to change a user's flair. +// If Text and CSSClass are empty, the request will just clear the user's flair. +type FlairChangeRequest struct { + User string + Text string + CSSClass string +} + +// FlairChangeResponse represents a response to a FlairChangeRequest. +type FlairChangeResponse struct { + // Whether or not the request was successful. + OK bool `json:"ok"` + Status string `json:"status"` + Warnings map[string]string `json:"warnings,omitempty"` + Errors map[string]string `json:"errors,omitempty"` +} + +// GetUserFlairs returns the user flairs from the subreddit. +func (s *FlairService) GetUserFlairs(ctx context.Context, subreddit string) ([]*Flair, *Response, error) { + path := fmt.Sprintf("r/%s/api/user_flair_v2", subreddit) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + var flairs []*Flair + resp, err := s.client.Do(ctx, req, &flairs) + if err != nil { + return nil, resp, err + } + + return flairs, resp, nil +} + +// GetPostFlairs returns the post flairs from the subreddit. +func (s *FlairService) GetPostFlairs(ctx context.Context, subreddit string) ([]*Flair, *Response, error) { + path := fmt.Sprintf("r/%s/api/link_flair_v2", subreddit) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + var flairs []*Flair + resp, err := s.client.Do(ctx, req, &flairs) + if err != nil { + return nil, resp, err + } + + return flairs, resp, nil +} + +// ListUserFlairs returns all flairs of individual users in the subreddit. +func (s *FlairService) ListUserFlairs(ctx context.Context, subreddit string) ([]*FlairSummary, *Response, error) { + path := fmt.Sprintf("r/%s/api/flairlist", subreddit) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + UserFlairs []*FlairSummary `json:"users"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.UserFlairs, resp, nil +} + +// Configure the subreddit's flair settings. +func (s *FlairService) Configure(ctx context.Context, subreddit string, request *FlairConfigureRequest) (*Response, error) { + if request == nil { + return nil, errors.New("*FlairConfigureRequest: cannot be nil") + } + + path := fmt.Sprintf("r/%s/api/flairconfig", subreddit) + + form, err := query.Values(request) + if err != nil { + return nil, err + } + form.Set("api_type", "json") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Enable your flair in the subreddit. +func (s *FlairService) Enable(ctx context.Context, subreddit string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/setflairenabled", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_enabled", "true") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Disable your flair in the subreddit. +func (s *FlairService) Disable(ctx context.Context, subreddit string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/setflairenabled", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_enabled", "false") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UpsertUserTemplate creates a user flair template, or updates it if the request.ID is valid. +// It returns the created/updated flair template. +func (s *FlairService) UpsertUserTemplate(ctx context.Context, subreddit string, request *FlairTemplateCreateOrUpdateRequest) (*FlairTemplate, *Response, error) { + if request == nil { + return nil, nil, errors.New("*FlairTemplateCreateOrUpdateRequest: cannot be nil") + } + + path := fmt.Sprintf("r/%s/api/flairtemplate_v2", subreddit) + + form, err := query.Values(request) + if err != nil { + return nil, nil, err + } + form.Set("api_type", "json") + form.Set("flair_type", "USER_FLAIR") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(FlairTemplate) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// UpsertPostTemplate creates a post flair template, or updates it if the request.ID is valid. +// It returns the created/updated flair template. +func (s *FlairService) UpsertPostTemplate(ctx context.Context, subreddit string, request *FlairTemplateCreateOrUpdateRequest) (*FlairTemplate, *Response, error) { + if request == nil { + return nil, nil, errors.New("*FlairTemplateCreateOrUpdateRequest: cannot be nil") + } + + path := fmt.Sprintf("r/%s/api/flairtemplate_v2", subreddit) + + form, err := query.Values(request) + if err != nil { + return nil, nil, err + } + form.Set("api_type", "json") + form.Set("flair_type", "LINK_FLAIR") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(FlairTemplate) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Delete the flair of the user. +func (s *FlairService) Delete(ctx context.Context, subreddit, username string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/deleteflair", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// DeleteTemplate deletes the flair template via its id. +func (s *FlairService) DeleteTemplate(ctx context.Context, subreddit, id string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/deleteflairtemplate", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_template_id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// DeleteAllUserTemplates deletes all user flair templates. +func (s *FlairService) DeleteAllUserTemplates(ctx context.Context, subreddit string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/clearflairtemplates", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_type", "USER_FLAIR") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// DeleteAllPostTemplates deletes all post flair templates. +func (s *FlairService) DeleteAllPostTemplates(ctx context.Context, subreddit string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/clearflairtemplates", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_type", "LINK_FLAIR") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// ReorderUserTemplates reorders the user flair templates in the order provided in the slice. +// The order should contain every single flair id of this flair type; omitting any id will result in an error. +func (s *FlairService) ReorderUserTemplates(ctx context.Context, subreddit string, ids []string) (*Response, error) { + path := fmt.Sprintf("api/v1/%s/flair_template_order/USER_FLAIR", subreddit) + req, err := s.client.NewJSONRequest(http.MethodPatch, path, ids) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +// ReorderPostTemplates reorders the post flair templates in the order provided in the slice. +// The order should contain every single flair id of this flair type; omitting any id will result in an error. +func (s *FlairService) ReorderPostTemplates(ctx context.Context, subreddit string, ids []string) (*Response, error) { + path := fmt.Sprintf("api/v1/%s/flair_template_order/LINK_FLAIR", subreddit) + req, err := s.client.NewJSONRequest(http.MethodPatch, path, ids) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +// Choices returns a list of flairs you can assign to yourself in the subreddit, and your current one. +func (s *FlairService) Choices(ctx context.Context, subreddit string) ([]*FlairChoice, *FlairChoice, *Response, error) { + return s.ChoicesOf(ctx, subreddit, s.client.Username) +} + +// ChoicesOf returns a list of flairs the user can assign to themself in the subreddit, and their current one. +// Unless the user is you, this only works if you're a moderator of the subreddit. +func (s *FlairService) ChoicesOf(ctx context.Context, subreddit, username string) ([]*FlairChoice, *FlairChoice, *Response, error) { + path := fmt.Sprintf("r/%s/api/flairselector", subreddit) + form := url.Values{} + form.Set("name", username) + return s.choices(ctx, path, form) +} + +// ChoicesForPost returns a list of flairs you can assign to an existing post, and the current one assigned to it. +// If the post isn't yours, this only works if you're the moderator of the subreddit it's in. +func (s *FlairService) ChoicesForPost(ctx context.Context, postID string) ([]*FlairChoice, *FlairChoice, *Response, error) { + path := "api/flairselector" + form := url.Values{} + form.Set("link", postID) + return s.choices(ctx, path, form) +} + +// ChoicesForNewPost returns a list of flairs you can assign to a new post in a subreddit. +func (s *FlairService) ChoicesForNewPost(ctx context.Context, subreddit string) ([]*FlairChoice, *Response, error) { + path := fmt.Sprintf("r/%s/api/flairselector", subreddit) + + form := url.Values{} + form.Set("is_newlink", "true") + + choices, _, resp, err := s.choices(ctx, path, form) + return choices, resp, err +} + +func (s *FlairService) choices(ctx context.Context, path string, form url.Values) ([]*FlairChoice, *FlairChoice, *Response, error) { + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, nil, err + } + + root := new(struct { + Choices []*FlairChoice `json:"choices"` + Current *FlairChoice `json:"current"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, nil, resp, err + } + + return root.Choices, root.Current, resp, nil +} + +// Select a flair to display next to your username in the subreddit. +func (s *FlairService) Select(ctx context.Context, subreddit string, request *FlairSelectRequest) (*Response, error) { + return s.Assign(ctx, subreddit, s.client.Username, request) +} + +// Assign a flair to another user in the subreddit. +// This only works if you're a moderator of the subreddit, or if the user is you. +func (s *FlairService) Assign(ctx context.Context, subreddit, user string, request *FlairSelectRequest) (*Response, error) { + if request == nil { + return nil, errors.New("*FlairSelectRequest: cannot be nil") + } + + path := fmt.Sprintf("r/%s/api/selectflair", subreddit) + + form, err := query.Values(request) + if err != nil { + return nil, err + } + form.Set("api_type", "json") + form.Set("name", user) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// SelectForPost assigns a flair to the post. +// If the post isn't yours, you have to be a moderator of the post's subreddit for this to work. +func (s *FlairService) SelectForPost(ctx context.Context, postID string, request *FlairSelectRequest) (*Response, error) { + if request == nil { + return nil, errors.New("*FlairSelectRequest: cannot be nil") + } + + path := "api/selectflair" + + form, err := query.Values(request) + if err != nil { + return nil, err + } + form.Set("api_type", "json") + form.Set("link", postID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// RemoveFromPost removes the flair from the post. +// If the post isn't yours, you have to be a moderator of the post's subreddit for this to work. +func (s *FlairService) RemoveFromPost(ctx context.Context, postID string) (*Response, error) { + path := "api/selectflair" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("link", postID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Change the flair of multiple users in the subreddit at once. +// You have to be a moderator of the subreddit for this to work. +func (s *FlairService) Change(ctx context.Context, subreddit string, requests []FlairChangeRequest) ([]*FlairChangeResponse, *Response, error) { + if len(requests) == 0 || len(requests) > 100 { + return nil, nil, errors.New("requests: must provide between 1 and 100") + } + + records := make([][]string, len(requests)) + for i, req := range requests { + records[i] = []string{req.User, req.Text, req.CSSClass} + } + + buf := new(bytes.Buffer) + w := csv.NewWriter(buf) + + err := w.WriteAll(records) + if err != nil { + return nil, nil, err + } + + err = w.Error() + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("r/%s/api/flaircsv", subreddit) + + form := url.Values{} + form.Set("flair_csv", buf.String()) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + var root []*FlairChangeResponse + resp, err := s.client.Do(ctx, req, &root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} diff --git a/v2/reddit/flair_test.go b/v2/reddit/flair_test.go new file mode 100644 index 0000000..dd92533 --- /dev/null +++ b/v2/reddit/flair_test.go @@ -0,0 +1,701 @@ +package reddit + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +var expectedUserFlairs = []*Flair{ + { + ID: "b8a1c822-3feb-11e8-88e1-0e5f55d58ce0", + Type: "text", + Text: "Beginner", + + Color: "dark", + BackgroundColor: "", + CSSClass: "Beginner1", + + Editable: false, + ModOnly: false, + }, + { + ID: "b8ea0fce-3feb-11e8-af7a-0e263a127cf8", + Text: "Moderator", + Type: "text", + + Color: "dark", + BackgroundColor: "", + CSSClass: "Moderator1", + + Editable: false, + ModOnly: true, + }, +} + +var expectedPostFlairs = []*Flair{ + { + ID: "305b503e-da60-11ea-9681-0e9f1d580d2d", + Type: "richtext", + Text: "test", + + Color: "light", + BackgroundColor: "#373c3f", + CSSClass: "test", + + Editable: false, + ModOnly: true, + }, +} + +var expectedListUserFlairs = []*FlairSummary{ + { + User: "TestUser1", + Text: "TestFlair1", + }, + { + User: "TestUser2", + Text: "TestFlair2", + }, +} + +var expectedFlairTemplate = &FlairTemplate{ + ID: "be0a6110-f23c-11ea-862f-0e08890d7323", + Type: "LINK_FLAIR", + ModOnly: false, + + AllowableContent: "all", + Text: "lol", + TextType: "richtext", + TextColor: "dark", + TextEditable: false, + RichText: []map[string]string{ + {"e": "text", "t": "lol"}, + }, + + OverrideCSS: false, + MaxEmojis: 1, + BackgroundColor: "#fafafa", + CSSClass: "", +} + +var expectedFlairChoices = []*FlairChoice{ + { + TemplateID: "c4edd5ce-40e8-11e7-b814-0ef91bd65558", + Text: "Reddit API", + Editable: false, + Position: "left", + CSSClass: "", + }, + { + TemplateID: "49bb3d06-0dad-11e7-b897-0e42c2400b7a", + Text: "PRAW", + Editable: false, + Position: "left", + CSSClass: "", + }, + { + TemplateID: "f1905376-40e9-11e7-a0dc-0e2f53ef3712", + Text: "snoowrap", + Editable: false, + Position: "left", + CSSClass: "", + }, + { + TemplateID: "03dc6ea8-40e9-11e7-8abb-0eb85aed0bce", + Text: "Other API Wrapper", + Editable: false, + Position: "left", + CSSClass: "", + }, +} + +var expectedFlairChoice = &FlairChoice{ + TemplateID: "03dc6ea8-40e9-11e7-8abb-0eb85aed0bce", + Text: "Other API Wrapper", + Editable: false, + Position: "left", + CSSClass: "", +} + +var expectedFlairChanges = []*FlairChangeResponse{ + { + OK: false, + Status: "skipped", + Warnings: map[string]string{}, + Errors: map[string]string{ + "user": "unable to resolve user `testuser1', ignoring", + }, + }, + { + OK: true, + Status: "added flair for user testuser2", + Warnings: map[string]string{}, + Errors: map[string]string{}, + }, + { + OK: true, + Status: "added flair for user testuser3", + Warnings: map[string]string{}, + Errors: map[string]string{}, + }, + { + OK: true, + Status: "removed flair for user testuser4", + Warnings: map[string]string{}, + Errors: map[string]string{}, + }, +} + +func TestFlairService_GetUserFlairs(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/user-flairs.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/user_flair_v2", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + userFlairs, _, err := client.Flair.GetUserFlairs(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedUserFlairs, userFlairs) +} + +func TestFlairService_GetPostFlairs(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/post-flairs.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/link_flair_v2", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + postFlairs, _, err := client.Flair.GetPostFlairs(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedPostFlairs, postFlairs) +} + +func TestFlairService_ListUserFlairs(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/list-user-flairs.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/flairlist", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + userFlairs, _, err := client.Flair.ListUserFlairs(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedListUserFlairs, userFlairs) +} + +func TestFlairService_Configure(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/flairconfig", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_enabled", "true") + form.Set("flair_position", "right") + form.Set("flair_self_assign_enabled", "false") + form.Set("link_flair_position", "left") + form.Set("link_flair_self_assign_enabled", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.Configure(ctx, "testsubreddit", nil) + require.EqualError(t, err, "*FlairConfigureRequest: cannot be nil") + + _, err = client.Flair.Configure(ctx, "testsubreddit", &FlairConfigureRequest{ + UserFlairEnabled: Bool(true), + UserFlairPosition: "right", + UserFlairSelfAssignEnabled: Bool(false), + PostFlairPosition: "left", + PostFlairSelfAssignEnabled: Bool(false), + }) + require.NoError(t, err) +} + +func TestFlairService_Enable(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/setflairenabled", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_enabled", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.Enable(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestFlairService_Disable(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/setflairenabled", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_enabled", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.Disable(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestFlairService_UpsertUserTemplate(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/flair-template.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/flairtemplate_v2", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_type", "USER_FLAIR") + form.Set("allowable_content", "all") + form.Set("text", "testtext") + form.Set("text_color", "dark") + form.Set("text_editable", "false") + form.Set("mod_only", "true") + form.Set("max_emojis", "5") + form.Set("background_color", "transparent") + form.Set("css_class", "testclass") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.Flair.UpsertUserTemplate(ctx, "testsubreddit", nil) + require.EqualError(t, err, "*FlairTemplateCreateOrUpdateRequest: cannot be nil") + + flairTemplate, _, err := client.Flair.UpsertUserTemplate(ctx, "testsubreddit", &FlairTemplateCreateOrUpdateRequest{ + AllowableContent: "all", + ModOnly: Bool(true), + Text: "testtext", + TextColor: "dark", + TextEditable: Bool(false), + MaxEmojis: Int(5), + BackgroundColor: "transparent", + CSSClass: "testclass", + }) + require.NoError(t, err) + require.Equal(t, expectedFlairTemplate, flairTemplate) +} + +func TestFlairService_UpsertPostTemplate(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/flair-template.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/flairtemplate_v2", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_type", "LINK_FLAIR") + form.Set("flair_template_id", "testid") + form.Set("allowable_content", "text") + form.Set("text", "testtext") + form.Set("text_color", "light") + form.Set("text_editable", "true") + form.Set("mod_only", "false") + form.Set("background_color", "#fafafa") + form.Set("css_class", "testclass") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.Flair.UpsertPostTemplate(ctx, "testsubreddit", nil) + require.EqualError(t, err, "*FlairTemplateCreateOrUpdateRequest: cannot be nil") + + flairTemplate, _, err := client.Flair.UpsertPostTemplate(ctx, "testsubreddit", &FlairTemplateCreateOrUpdateRequest{ + ID: "testid", + AllowableContent: "text", + ModOnly: Bool(false), + Text: "testtext", + TextColor: "light", + TextEditable: Bool(true), + BackgroundColor: "#fafafa", + CSSClass: "testclass", + }) + require.NoError(t, err) + require.Equal(t, expectedFlairTemplate, flairTemplate) +} + +func TestFlairService_Delete(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/deleteflair", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.Delete(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestFlairService_DeleteTemplate(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/deleteflairtemplate", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_template_id", "testtemplate") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.DeleteTemplate(ctx, "testsubreddit", "testtemplate") + require.NoError(t, err) +} + +func TestFlairService_DeleteAllUserTemplates(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/clearflairtemplates", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_type", "USER_FLAIR") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.DeleteAllUserTemplates(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestFlairService_DeleteAllPostTemplates(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/clearflairtemplates", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("flair_type", "LINK_FLAIR") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.DeleteAllPostTemplates(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestFlairService_ReorderUserTemplates(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/testsubreddit/flair_template_order/USER_FLAIR", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + + var ids []string + err := json.NewDecoder(r.Body).Decode(&ids) + require.NoError(t, err) + require.Equal(t, []string{"test1", "test2", "test3", "test4"}, ids) + }) + + _, err := client.Flair.ReorderUserTemplates(ctx, "testsubreddit", []string{"test1", "test2", "test3", "test4"}) + require.NoError(t, err) +} + +func TestFlairService_ReorderPostTemplates(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/testsubreddit/flair_template_order/LINK_FLAIR", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + + var ids []string + err := json.NewDecoder(r.Body).Decode(&ids) + require.NoError(t, err) + require.Equal(t, []string{"test1", "test2", "test3", "test4"}, ids) + }) + + _, err := client.Flair.ReorderPostTemplates(ctx, "testsubreddit", []string{"test1", "test2", "test3", "test4"}) + require.NoError(t, err) +} + +func TestFlairService_Choices(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/choices.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/flairselector", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("name", "user1") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + choices, current, _, err := client.Flair.Choices(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedFlairChoices, choices) + require.Equal(t, expectedFlairChoice, current) +} + +func TestFlairService_ChoicesOf(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/choices.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/flairselector", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("name", "testuser") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + choices, current, _, err := client.Flair.ChoicesOf(ctx, "testsubreddit", "testuser") + require.NoError(t, err) + require.Equal(t, expectedFlairChoices, choices) + require.Equal(t, expectedFlairChoice, current) +} + +func TestFlairService_ChoicesForPost(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/choices.json") + require.NoError(t, err) + + mux.HandleFunc("/api/flairselector", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("link", "t3_123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + choices, current, _, err := client.Flair.ChoicesForPost(ctx, "t3_123") + require.NoError(t, err) + require.Equal(t, expectedFlairChoices, choices) + require.Equal(t, expectedFlairChoice, current) +} + +func TestFlairService_ChoicesForNewPost(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/choices.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/flairselector", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("is_newlink", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + choices, _, err := client.Flair.ChoicesForNewPost(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedFlairChoices, choices) +} + +func TestFlairService_Select(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/selectflair", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "user1") + form.Set("flair_template_id", "id123") + form.Set("text", "text123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.Select(ctx, "testsubreddit", nil) + require.EqualError(t, err, "*FlairSelectRequest: cannot be nil") + + _, err = client.Flair.Select(ctx, "testsubreddit", &FlairSelectRequest{ + ID: "id123", + Text: "text123", + }) + require.NoError(t, err) +} + +func TestFlairService_Assign(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/selectflair", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("flair_template_id", "id123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.Assign(ctx, "testsubreddit", "testuser", nil) + require.EqualError(t, err, "*FlairSelectRequest: cannot be nil") + + _, err = client.Flair.Assign(ctx, "testsubreddit", "testuser", &FlairSelectRequest{ + ID: "id123", + }) + require.NoError(t, err) +} + +func TestFlairService_SelectForPost(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/selectflair", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("link", "t3_123") + form.Set("flair_template_id", "id123") + form.Set("text", "text123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.SelectForPost(ctx, "t3_123", nil) + require.EqualError(t, err, "*FlairSelectRequest: cannot be nil") + + _, err = client.Flair.SelectForPost(ctx, "t3_123", &FlairSelectRequest{ + ID: "id123", + Text: "text123", + }) + require.NoError(t, err) +} + +func TestFlairService_RemoveFromPost(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/selectflair", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("link", "t3_123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Flair.RemoveFromPost(ctx, "t3_123") + require.NoError(t, err) +} + +func TestFlairService_Change(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/flair/csv-change.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/flaircsv", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("flair_csv", `testuser1,testtext1,testclass1 +testuser2,testtext2,testclass2 +testuser3,testtext3,testclass3 +testuser4,testtext4,testclass4 +`) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.Flair.Change(ctx, "testsubreddit", nil) + require.EqualError(t, err, "requests: must provide between 1 and 100") + + changes, _, err := client.Flair.Change(ctx, "testsubreddit", []FlairChangeRequest{ + {"testuser1", "testtext1", "testclass1"}, + {"testuser2", "testtext2", "testclass2"}, + {"testuser3", "testtext3", "testclass3"}, + {"testuser4", "testtext4", "testclass4"}, + }) + require.NoError(t, err) + require.Equal(t, expectedFlairChanges, changes) +} diff --git a/v2/reddit/gold.go b/v2/reddit/gold.go new file mode 100644 index 0000000..6b63b44 --- /dev/null +++ b/v2/reddit/gold.go @@ -0,0 +1,49 @@ +package reddit + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// GoldService handles communication with the gold +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_gold +type GoldService struct { + client *Client +} + +// Gild the post or comment via its full ID. +// This requires you to own Reddit coins and will consume them. +func (s *GoldService) Gild(ctx context.Context, id string) (*Response, error) { + path := fmt.Sprintf("api/v1/gold/gild/%s", id) + req, err := s.client.NewRequest(http.MethodPost, path, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +// Give the user between 1 and 36 (inclusive) months of gold. +// This requires you to own Reddit coins and will consume them. +func (s *GoldService) Give(ctx context.Context, username string, months int) (*Response, error) { + if months < 1 || months > 36 { + return nil, errors.New("months: must be between 1 and 36 (inclusive)") + } + + path := fmt.Sprintf("api/v1/gold/give/%s", username) + + form := url.Values{} + form.Set("months", strconv.Itoa(months)) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/gold_test.go b/v2/reddit/gold_test.go new file mode 100644 index 0000000..11de810 --- /dev/null +++ b/v2/reddit/gold_test.go @@ -0,0 +1,44 @@ +package reddit + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGoldService_Gild(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/gold/gild/t1_test", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + }) + + _, err := client.Gold.Gild(ctx, "t1_test") + require.NoError(t, err) +} + +func TestGoldService_Give(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/gold/give/testuser", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("months", "1") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Gold.Give(ctx, "testuser", 0) + require.EqualError(t, err, "months: must be between 1 and 36 (inclusive)") + + _, err = client.Gold.Give(ctx, "testuser", 37) + require.EqualError(t, err, "months: must be between 1 and 36 (inclusive)") + + _, err = client.Gold.Give(ctx, "testuser", 1) + require.NoError(t, err) +} diff --git a/v2/reddit/listings.go b/v2/reddit/listings.go new file mode 100644 index 0000000..a7b8bb1 --- /dev/null +++ b/v2/reddit/listings.go @@ -0,0 +1,40 @@ +package reddit + +import ( + "context" + "fmt" + "strings" +) + +// ListingsService handles communication with the listing +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_listings +type ListingsService struct { + client *Client +} + +// Get posts, comments, and subreddits from their full IDs. +func (s *ListingsService) Get(ctx context.Context, ids ...string) ([]*Post, []*Comment, []*Subreddit, *Response, error) { + path := "api/info" + params := struct { + IDs []string `url:"id,omitempty,comma"` + }{ids} + + l, resp, err := s.client.getListing(ctx, path, params) + if err != nil { + return nil, nil, nil, resp, err + } + + return l.Posts(), l.Comments(), l.Subreddits(), resp, nil +} + +// GetPosts returns posts from their full IDs. +func (s *ListingsService) GetPosts(ctx context.Context, ids ...string) ([]*Post, *Response, error) { + path := fmt.Sprintf("by_id/%s", strings.Join(ids, ",")) + l, resp, err := s.client.getListing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} diff --git a/v2/reddit/listings_test.go b/v2/reddit/listings_test.go new file mode 100644 index 0000000..3cac649 --- /dev/null +++ b/v2/reddit/listings_test.go @@ -0,0 +1,183 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedListingPosts = []*Post{ + { + ID: "i2gvg4", + FullID: "t3_i2gvg4", + Created: &Timestamp{time.Date(2020, 8, 2, 18, 23, 8, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/test/comments/i2gvg4/this_is_a_title/", + URL: "https://www.reddit.com/r/test/comments/i2gvg4/this_is_a_title/", + + Title: "This is a title", + Body: "This is some text", + + Likes: Bool(true), + Score: 1, + UpvoteRatio: 1, + NumberOfComments: 1, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8202, + + Author: "v_95", + AuthorID: "t2_164ab8", + + IsSelfPost: true, + }, +} + +var expectedListingComments = []*Comment{ + { + ID: "g05v931", + FullID: "t1_g05v931", + Created: &Timestamp{time.Date(2020, 8, 3, 1, 15, 40, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + ParentID: "t3_i2gvg4", + Permalink: "/r/test/comments/i2gvg4/this_is_a_title/g05v931/", + + Body: "Test comment", + Author: "v_95", + AuthorID: "t2_164ab8", + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + + Likes: Bool(true), + + Score: 1, + Controversiality: 0, + + PostID: "t3_i2gvg4", + + IsSubmitter: true, + }, +} + +var expectedListingSubreddits = []*Subreddit{ + { + ID: "2qh23", + FullID: "t5_2qh23", + Created: &Timestamp{time.Date(2008, 1, 25, 5, 11, 28, 0, time.UTC)}, + + URL: "/r/test/", + Name: "test", + NamePrefixed: "r/test", + Title: "Testing", + Type: "public", + + Subscribers: 8202, + Subscribed: true, + }, +} + +var expectedListingPosts2 = []*Post{ + { + ID: "i2gvg4", + FullID: "t3_i2gvg4", + Created: &Timestamp{time.Date(2020, 8, 2, 18, 23, 8, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/test/comments/i2gvg4/this_is_a_title/", + URL: "https://www.reddit.com/r/test/comments/i2gvg4/this_is_a_title/", + + Title: "This is a title", + Body: "This is some text", + + Likes: Bool(true), + Score: 1, + UpvoteRatio: 1, + NumberOfComments: 1, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8201, + + Author: "v_95", + AuthorID: "t2_164ab8", + + IsSelfPost: true, + }, + { + ID: "i2gvs1", + FullID: "t3_i2gvs1", + Created: &Timestamp{time.Date(2020, 8, 2, 18, 23, 37, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/test/comments/i2gvs1/this_is_a_title/", + URL: "http://example.com", + + Title: "This is a title", + + Likes: Bool(true), + Score: 1, + UpvoteRatio: 1, + NumberOfComments: 0, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8201, + + Author: "v_95", + AuthorID: "t2_164ab8", + }, +} + +func TestListingsService_Get(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/listings/posts-comments-subreddits.json") + require.NoError(t, err) + + mux.HandleFunc("/api/info", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("id", "t5_2qh23,t3_i2gvg4,t1_g05v931") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + posts, comments, subreddits, _, err := client.Listings.Get(ctx, "t5_2qh23", "t3_i2gvg4", "t1_g05v931") + require.NoError(t, err) + require.Equal(t, expectedListingPosts, posts) + require.Equal(t, expectedListingComments, comments) + require.Equal(t, expectedListingSubreddits, subreddits) +} + +func TestListingsService_GetPosts(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/listings/posts.json") + require.NoError(t, err) + + mux.HandleFunc("/by_id/t3_i2gvg4,t3_i2gwgz", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, _, err := client.Listings.GetPosts(ctx, "t3_i2gvg4", "t3_i2gwgz") + require.NoError(t, err) + require.Equal(t, expectedListingPosts2, posts) +} diff --git a/v2/reddit/live-thread.go b/v2/reddit/live-thread.go new file mode 100644 index 0000000..5dac208 --- /dev/null +++ b/v2/reddit/live-thread.go @@ -0,0 +1,610 @@ +package reddit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + + "github.com/google/go-querystring/query" +) + +// LiveThreadService handles communication with the live thread +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_live +type LiveThreadService struct { + client *Client +} + +// LiveThread is a thread on Reddit that provides real-time updates. +type LiveThread struct { + ID string `json:"id,omitempty"` + FullID string `json:"name,omitempty"` + Created *Timestamp `json:"created_utc,omitempty"` + + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Resources string `json:"resources,omitempty"` + + State string `json:"state,omitempty"` + ViewerCount int `json:"viewer_count"` + ViewerCountFuzzed bool `json:"viewer_count_fuzzed"` + + // Empty when a live thread has ended. + WebSocketURL string `json:"websocket_url,omitempty"` + + Announcement bool `json:"is_announcement"` + NSFW bool `json:"nsfw"` +} + +// LiveThreadUpdate is an update in a live thread. +type LiveThreadUpdate struct { + ID string `json:"id,omitempty"` + FullID string `json:"name,omitempty"` + Author string `json:"author,omitempty"` + Created *Timestamp `json:"created_utc,omitempty"` + + Body string `json:"body,omitempty"` + EmbeddedURLs []string `json:"embeds,omitempty"` + Stricken bool `json:"stricken"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (u *LiveThreadUpdate) UnmarshalJSON(b []byte) error { + root := new(struct { + ID string `json:"id"` + FullID string `json:"name"` + Author string `json:"author"` + Created *Timestamp `json:"created_utc"` + + Body string `json:"body"` + EmbeddedURLs []struct { + URL string `json:"url"` + } `json:"embeds"` + Stricken bool `json:"stricken"` + }) + + err := json.Unmarshal(b, root) + if err != nil { + return err + } + + u.ID = root.ID + u.FullID = root.FullID + u.Author = root.Author + u.Created = root.Created + + u.Body = root.Body + u.Stricken = root.Stricken + + for _, eu := range root.EmbeddedURLs { + u.EmbeddedURLs = append(u.EmbeddedURLs, eu.URL) + } + + return nil +} + +// LiveThreadCreateOrUpdateRequest represents a request to create/update a live thread. +type LiveThreadCreateOrUpdateRequest struct { + // No longer than 120 characters. + Title string `url:"title,omitempty"` + Description string `url:"description,omitempty"` + Resources string `url:"resources,omitempty"` + NSFW *bool `url:"nsfw,omitempty"` +} + +// LiveThreadContributor is a user that can contribute to a live thread. +type LiveThreadContributor struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Permissions []string `json:"permissions,omitempty"` +} + +// LiveThreadContributors is a list of users that can contribute to a live thread. +type LiveThreadContributors struct { + Current []*LiveThreadContributor `json:"current_contributors"` + // This is only filled if you are a contributor in the live thread with the "manage" permission. + Invited []*LiveThreadContributor `json:"invited_contributors,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (c *LiveThreadContributors) UnmarshalJSON(b []byte) error { + if len(b) == 0 { + return errors.New("no bytes to unmarshal") + } + + // neat trick taken from: + // https://www.calhoun.io/how-to-parse-json-that-varies-between-an-array-or-a-single-item-with-go + switch b[0] { + case '{': + return c.unmarshalSingle(b) + case '[': + return c.unmarshalMany(b) + } + + // This shouldn't really happen as the standard library seems to strip + // whitespace from the bytes being passed in, but just in case let's guess at + // multiple tags and fall back to a single one if that doesn't work. + err := c.unmarshalSingle(b) + if err != nil { + return c.unmarshalMany(b) + } + + return nil +} + +func (c *LiveThreadContributors) unmarshalSingle(b []byte) error { + root := new(struct { + Data struct { + Children []*LiveThreadContributor `json:"children"` + } `json:"data"` + }) + + err := json.Unmarshal(b, &root) + if err != nil { + return err + } + + c.Current = root.Data.Children + return nil +} + +func (c *LiveThreadContributors) unmarshalMany(b []byte) error { + var root [2]struct { + Data struct { + Children []*LiveThreadContributor `json:"children"` + } `json:"data"` + } + + err := json.Unmarshal(b, &root) + if err != nil { + return err + } + + c.Current = root[0].Data.Children + c.Invited = root[1].Data.Children + return nil +} + +// LiveThreadPermissions are the different permissions contributors have or don't have for a live thread. +// Read about them here: https://mods.reddithelp.com/hc/en-us/articles/360009381491-User-Management-moderators-and-permissions +type LiveThreadPermissions struct { + All bool `permission:"all"` + Close bool `permission:"close"` + Discussions bool `permission:"discussions"` + Edit bool `permission:"edit"` + Manage bool `permission:"manage"` + Settings bool `permission:"settings"` + // Posting updates to the thread. + Update bool `permission:"update"` +} + +func (p *LiveThreadPermissions) String() (s string) { + if p == nil { + return "+all" + } + + t := reflect.TypeOf(*p) + v := reflect.ValueOf(*p) + + for i := 0; i < t.NumField(); i++ { + if v.Field(i).Kind() != reflect.Bool { + continue + } + + permission := t.Field(i).Tag.Get("permission") + permitted := v.Field(i).Bool() + + if permitted { + s += "+" + } else { + s += "-" + } + + s += permission + + if i != t.NumField()-1 { + s += "," + } + } + + return +} + +// Now gets information about the currently featured live thread. +// This returns an empty 204 response if no thread is currently featured. +func (s *LiveThreadService) Now(ctx context.Context) (*LiveThread, *Response, error) { + path := "api/live/happening_now" + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + if err == io.EOF && resp != nil && resp.StatusCode == http.StatusNoContent { + return nil, resp, nil + } + return nil, resp, err + } + liveThread, _ := t.LiveThread() + return liveThread, resp, nil +} + +// Get information about a live thread. +func (s *LiveThreadService) Get(ctx context.Context, id string) (*LiveThread, *Response, error) { + path := fmt.Sprintf("live/%s/about", id) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + liveThread, _ := t.LiveThread() + return liveThread, resp, nil +} + +// GetMultiple gets information about multiple live threads. +func (s *LiveThreadService) GetMultiple(ctx context.Context, ids ...string) ([]*LiveThread, *Response, error) { + if len(ids) == 0 { + return nil, nil, errors.New("must provide at least 1 id") + } + path := fmt.Sprintf("api/live/by_id/%s", strings.Join(ids, ",")) + l, resp, err := s.client.getListing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + return l.LiveThreads(), resp, nil +} + +// Update the live thread by posting an update to it. +// Requires the "update" permission. +func (s *LiveThreadService) Update(ctx context.Context, id, text string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("body", text) + + path := fmt.Sprintf("api/live/%s/update", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Updates gets a list of updates posted in the live thread. +func (s *LiveThreadService) Updates(ctx context.Context, id string, opts *ListOptions) ([]*LiveThreadUpdate, *Response, error) { + path := fmt.Sprintf("live/%s", id) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.LiveThreadUpdates(), resp, nil +} + +// UpdateByID gets a specific update in the live thread by its id. +// The ID of the update is the "short" one, i.e. the one that doesn't start with "LiveUpdate_". +func (s *LiveThreadService) UpdateByID(ctx context.Context, threadID, updateID string) (*LiveThreadUpdate, *Response, error) { + path := fmt.Sprintf("live/%s/updates/%s", threadID, updateID) + + // this endpoint returns a listing + l, resp, err := s.client.getListing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + + var update *LiveThreadUpdate + updates := l.LiveThreadUpdates() + if len(updates) > 0 { + update = updates[0] + } + + return update, resp, nil +} + +// Discussions gets a list of discussions (posts) about the live thread. +func (s *LiveThreadService) Discussions(ctx context.Context, id string, opts *ListOptions) ([]*Post, *Response, error) { + path := fmt.Sprintf("live/%s/discussions", id) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} + +// Strike (mark incorrect and cross out) the content of an update. +// You must either be the author of the update or have the "edit" permission. +func (s *LiveThreadService) Strike(ctx context.Context, threadID, updateID string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", updateID) + + path := fmt.Sprintf("api/live/%s/strike_update", threadID) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Delete an update from the live thread. +// You must either be the author of the update or have the "edit" permission. +func (s *LiveThreadService) Delete(ctx context.Context, threadID, updateID string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", updateID) + + path := fmt.Sprintf("api/live/%s/delete_update", threadID) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Create a live thread and get its id. +func (s *LiveThreadService) Create(ctx context.Context, request *LiveThreadCreateOrUpdateRequest) (string, *Response, error) { + if request == nil { + return "", nil, errors.New("*LiveThreadCreateOrUpdateRequest: cannot be nil") + } + + form, err := query.Values(request) + if err != nil { + return "", nil, err + } + form.Set("api_type", "json") + + path := "api/live/create" + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return "", nil, err + } + + root := new(struct { + JSON struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } `json:"json"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return "", resp, err + } + + return root.JSON.Data.ID, resp, nil +} + +// Close the thread permanently, disallowing future updates. +func (s *LiveThreadService) Close(ctx context.Context, id string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + + path := fmt.Sprintf("api/live/%s/close_thread", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Configure the thread. +// Requires the "settings" permission. +func (s *LiveThreadService) Configure(ctx context.Context, id string, request *LiveThreadCreateOrUpdateRequest) (*Response, error) { + if request == nil { + return nil, errors.New("*LiveThreadCreateOrUpdateRequest: cannot be nil") + } + + form, err := query.Values(request) + if err != nil { + return nil, err + } + form.Set("api_type", "json") + + path := fmt.Sprintf("api/live/%s/edit", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +// Contributors gets a list of users that are contributors to the live thread. +// If you are a contributor and you have the "manage" permission (to manage contributors), you +// also get a list of invited contributors that haven't yet accepted/refused their invitation. +func (s *LiveThreadService) Contributors(ctx context.Context, id string) (*LiveThreadContributors, *Response, error) { + path := fmt.Sprintf("live/%s/contributors", id) + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(LiveThreadContributors) + resp, err := s.client.Do(ctx, req, &root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Accept a pending invite to contribute to the live thread. +func (s *LiveThreadService) Accept(ctx context.Context, id string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + + path := fmt.Sprintf("api/live/%s/accept_contributor_invite", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Leave the live thread by abdicating your status as contributor. +func (s *LiveThreadService) Leave(ctx context.Context, id string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + + path := fmt.Sprintf("api/live/%s/leave_contributor", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Invite another user to contribute to the live thread. +// If permissions is nil, all permissions will be granted. +// Requires the "manage" permission. +func (s *LiveThreadService) Invite(ctx context.Context, id, username string, permissions *LiveThreadPermissions) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", permissions.String()) + + path := fmt.Sprintf("api/live/%s/invite_contributor", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Uninvite a user that's been invited to contribute to a live thread via their full ID. +// Requires the "manage" permission. +func (s *LiveThreadService) Uninvite(ctx context.Context, threadID, userID string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", userID) + + path := fmt.Sprintf("api/live/%s/rm_contributor_invite", threadID) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// SetPermissions sets the permissions for the contributor in the live thread. +// If permissions is nil, all permissions will be granted. +// Requires the "manage" permission. +func (s *LiveThreadService) SetPermissions(ctx context.Context, id, username string, permissions *LiveThreadPermissions) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", "liveupdate_contributor") + form.Set("permissions", permissions.String()) + + path := fmt.Sprintf("api/live/%s/set_contributor_permissions", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// SetPermissionsForInvite sets the permissions for a contributor who's yet to accept/refuse their invite. +// If permissions is nil, all permissions will be granted. +// Requires the "manage" permission. +func (s *LiveThreadService) SetPermissionsForInvite(ctx context.Context, id, username string, permissions *LiveThreadPermissions) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", permissions.String()) + + path := fmt.Sprintf("api/live/%s/set_contributor_permissions", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Revoke a user's contributorship via their full ID. +// Requires the "manage" permission. +func (s *LiveThreadService) Revoke(ctx context.Context, threadID, userID string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", userID) + + path := fmt.Sprintf("api/live/%s/rm_contributor", threadID) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// HideDiscussion hides a linked post from the live thread's discussion sidebar. +// The postID should be the base36 ID of the post, i.e. not its full id. +func (s *LiveThreadService) HideDiscussion(ctx context.Context, threadID, postID string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("link", postID) + + path := fmt.Sprintf("api/live/%s/hide_discussion", threadID) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UnhideDiscussion unhides a linked post from the live thread's discussion sidebar. +// The postID should be the base36 ID of the post, i.e. not its full id. +func (s *LiveThreadService) UnhideDiscussion(ctx context.Context, threadID, postID string) (*Response, error) { + form := url.Values{} + form.Set("api_type", "json") + form.Set("link", postID) + + path := fmt.Sprintf("api/live/%s/unhide_discussion", threadID) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Report the live thread. +// The reason should be one of: +// spam, vote-manipulation, personal-information, sexualizing-minors, site-breaking +func (s *LiveThreadService) Report(ctx context.Context, id, reason string) (*Response, error) { + switch reason { + case "spam", "vote-manipulation", "personal-information", "sexualizing-minors", "site-breaking": + default: + return nil, errors.New("invalid reason for reporting live thread: " + reason) + } + + form := url.Values{} + form.Set("api_type", "json") + form.Set("type", reason) + + path := fmt.Sprintf("api/live/%s/report", id) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/live-thread_test.go b/v2/reddit/live-thread_test.go new file mode 100644 index 0000000..dc52715 --- /dev/null +++ b/v2/reddit/live-thread_test.go @@ -0,0 +1,687 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedLiveThread = &LiveThread{ + ID: "15nevtv8e54dh", + FullID: "LiveUpdateEvent_15nevtv8e54dh", + Created: &Timestamp{time.Date(2020, 9, 16, 1, 20, 27, 0, time.UTC)}, + + Title: "test", + Description: "test", + Resources: "", + + State: "live", + ViewerCount: 6, + ViewerCountFuzzed: true, + + WebSocketURL: "wss://ws-078adc7cb2099a9df.wss.redditmedia.com/live/15nevtv8e54dh?m=AQAA7rxiX6EpLYFCFZ0KJD4lVAPaMt0A1z2-xJ1b2dWCmxNIfMwL", + + Announcement: false, + NSFW: false, +} + +var expectedLiveThreads = []*LiveThread{ + { + ID: "15nevtv8e54dh", + FullID: "LiveUpdateEvent_15nevtv8e54dh", + Created: &Timestamp{time.Date(2020, 9, 16, 1, 20, 27, 0, time.UTC)}, + + Title: "test", + Description: "test", + Resources: "", + + State: "live", + ViewerCount: 6, + ViewerCountFuzzed: true, + + WebSocketURL: "wss://ws-078adc7cb2099a9df.wss.redditmedia.com/live/15nevtv8e54dh?m=AQAA7rxiX6EpLYFCFZ0KJD4lVAPaMt0A1z2-xJ1b2dWCmxNIfMwL", + + Announcement: false, + NSFW: false, + }, + { + ID: "15ndkho8e54dh", + FullID: "LiveUpdateEvent_15ndkho8e54dh", + Created: &Timestamp{time.Date(2020, 9, 16, 1, 20, 37, 0, time.UTC)}, + + Title: "test 2", + Description: "test 2", + Resources: "", + + State: "live", + ViewerCount: 6, + ViewerCountFuzzed: true, + + WebSocketURL: "wss://ws-078adc7cb2099a9df.wss.redditmedia.com/live/15ndkho8e54dh?m=AQAA7rxiX6EpLYFCFZ0KJD4lVAPaMt0A1z2-xJ1b2dWCmxNIfMwL", + + Announcement: false, + NSFW: false, + }, +} + +var expectedLiveThreadUpdates = []*LiveThreadUpdate{ + { + ID: "5e46cd94-f968-11ea-9a6a-0e1933241e7d", + FullID: "LiveUpdate_5e46cd94-f968-11ea-9a6a-0e1933241e7d", + Author: "testuser1", + Created: &Timestamp{time.Date(2020, 9, 18, 4, 35, 24, 0, time.UTC)}, + + Body: "test 2", + EmbeddedURLs: []string{"https://example.com", "https://reddit.com"}, + + Stricken: true, + }, + { + ID: "fc44f204-f964-11ea-b148-0e2e56a0425f", + FullID: "LiveUpdate_fc44f204-f964-11ea-b148-0e2e56a0425f", + Author: "testuser1", + Created: &Timestamp{time.Date(2020, 9, 18, 4, 11, 11, 0, time.UTC)}, + + Body: "test 1", + + Stricken: true, + }, +} + +var expectedLiveThreadUpdate = &LiveThreadUpdate{ + ID: "fc44f204-f964-11ea-b148-0e2e56a0425f", + FullID: "LiveUpdate_fc44f204-f964-11ea-b148-0e2e56a0425f", + Author: "testuser1", + Created: &Timestamp{time.Date(2020, 9, 18, 4, 11, 11, 0, time.UTC)}, + + Body: "test 1", + + Stricken: true, +} + +var expectedLiveThreadDiscussions = []*Post{ + { + ID: "test1", + FullID: "t3_test1", + Created: &Timestamp{time.Date(2020, 9, 16, 12, 37, 31, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/live/comments/test1/test_title/", + URL: "https://www.reddit.com/live/15nfp4mtfbo14/", + + Title: "test title", + + Score: 22, + UpvoteRatio: 0.9, + NumberOfComments: 1, + + SubredditName: "live", + SubredditNamePrefixed: "r/live", + SubredditID: "t5_32o7w", + SubredditSubscribers: 24501, + + Author: "TestUser", + AuthorID: "t2_test1", + }, + { + ID: "test2", + FullID: "t3_test2", + Created: &Timestamp{time.Date(2020, 9, 16, 12, 37, 1, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/live/comments/test2/test_title/", + URL: "https://www.reddit.com/live/15nfp4mtfbo14/", + + Title: "test title", + + Score: 71, + UpvoteRatio: 0.97, + NumberOfComments: 34, + + SubredditName: "live", + SubredditNamePrefixed: "r/live", + SubredditID: "t5_32o7w", + SubredditSubscribers: 24501, + + Author: "TestUser", + AuthorID: "t2_test1", + }, +} + +var expectedLiveThreadContributors = &LiveThreadContributors{ + Current: []*LiveThreadContributor{ + {ID: "t2_test1", Name: "test1", Permissions: []string{"all"}}, + {ID: "t2_test2", Name: "test2", Permissions: []string{"all"}}, + }, + Invited: nil, +} + +var expectedLiveThreadContributorsAndInvited = &LiveThreadContributors{ + Current: []*LiveThreadContributor{ + {ID: "t2_test1", Name: "test1", Permissions: []string{"all"}}, + {ID: "t2_test2", Name: "test2", Permissions: []string{"all"}}, + }, + Invited: []*LiveThreadContributor{ + {ID: "t2_test3", Name: "test3", Permissions: []string{"manage", "discussions"}}, + }, +} + +func TestLiveThreadService_Get(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/live-thread/live-thread.json") + require.NoError(t, err) + + mux.HandleFunc("/live/id123/about", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + liveThread, _, err := client.LiveThread.Get(ctx, "id123") + require.NoError(t, err) + require.Equal(t, expectedLiveThread, liveThread) +} + +func TestLiveThreadService_Now(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/live-thread/live-thread.json") + require.NoError(t, err) + + mux.HandleFunc("/api/live/happening_now", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + liveThread, _, err := client.LiveThread.Now(ctx) + require.NoError(t, err) + require.Equal(t, expectedLiveThread, liveThread) +} + +func TestLiveThreadService_Now_NoContent(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/happening_now", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(204) + }) + + liveThread, _, err := client.LiveThread.Now(ctx) + require.NoError(t, err) + require.Nil(t, liveThread) +} + +func TestLiveThreadService_GetMultiple(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/live-thread/live-threads.json") + require.NoError(t, err) + + mux.HandleFunc("/api/live/by_id/id1,id2", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + _, _, err = client.LiveThread.GetMultiple(ctx) + require.EqualError(t, err, "must provide at least 1 id") + + liveThreads, _, err := client.LiveThread.GetMultiple(ctx, "id1", "id2") + require.NoError(t, err) + require.Equal(t, expectedLiveThreads, liveThreads) +} + +func TestLiveThreadService_Update(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/update", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("body", "test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Update(ctx, "id123", "test") + require.NoError(t, err) +} + +func TestLiveThreadService_Updates(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/live-thread/updates.json") + require.NoError(t, err) + + mux.HandleFunc("/live/id123", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + liveThreadUpdates, _, err := client.LiveThread.Updates(ctx, "id123", nil) + require.NoError(t, err) + require.Equal(t, expectedLiveThreadUpdates, liveThreadUpdates) +} + +func TestLiveThreadService_UpdateByID(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/live-thread/update.json") + require.NoError(t, err) + + mux.HandleFunc("/live/id123/updates/update123", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + liveThreadUpdate, _, err := client.LiveThread.UpdateByID(ctx, "id123", "update123") + require.NoError(t, err) + require.Equal(t, expectedLiveThreadUpdate, liveThreadUpdate) +} + +func TestLiveThreadService_Discussions(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/live-thread/discussions.json") + require.NoError(t, err) + + mux.HandleFunc("/live/id123/discussions", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + liveThreadDiscussions, _, err := client.LiveThread.Discussions(ctx, "id123", nil) + require.NoError(t, err) + require.Equal(t, expectedLiveThreadDiscussions, liveThreadDiscussions) +} + +func TestLiveThreadService_Strike(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/strike_update", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "update123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Strike(ctx, "id123", "update123") + require.NoError(t, err) +} + +func TestLiveThreadService_Delete(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/delete_update", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "update123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Delete(ctx, "id123", "update123") + require.NoError(t, err) +} + +func TestLiveThreadService_Create(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/create", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("title", "testtitle") + form.Set("description", "testdescription") + form.Set("resources", "testresources") + form.Set("nsfw", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, `{ + "json": { + "data": { + "id": "id123" + }, + "errors": [] + } + }`) + }) + + _, _, err := client.LiveThread.Create(ctx, nil) + require.EqualError(t, err, "*LiveThreadCreateOrUpdateRequest: cannot be nil") + + id, _, err := client.LiveThread.Create(ctx, &LiveThreadCreateOrUpdateRequest{ + Title: "testtitle", + Description: "testdescription", + Resources: "testresources", + NSFW: Bool(true), + }) + require.NoError(t, err) + require.Equal(t, "id123", id) +} + +func TestLiveThreadService_Close(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/close_thread", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Close(ctx, "id123") + require.NoError(t, err) +} + +func TestLiveThreadService_Configure(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/edit", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("title", "testtitle") + form.Set("description", "testdescription") + form.Set("resources", "testresources") + form.Set("nsfw", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, `{ + "json": { + "data": { + "id": "id123" + }, + "errors": [] + } + }`) + }) + + _, err := client.LiveThread.Configure(ctx, "id123", nil) + require.EqualError(t, err, "*LiveThreadCreateOrUpdateRequest: cannot be nil") + + _, err = client.LiveThread.Configure(ctx, "id123", &LiveThreadCreateOrUpdateRequest{ + Title: "testtitle", + Description: "testdescription", + Resources: "testresources", + NSFW: Bool(true), + }) + require.NoError(t, err) +} + +func TestLiveThreadService_Contributors(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/live-thread/contributors.json") + require.NoError(t, err) + + mux.HandleFunc("/live/id123/contributors", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + contributors, _, err := client.LiveThread.Contributors(ctx, "id123") + require.NoError(t, err) + require.Equal(t, expectedLiveThreadContributors, contributors) +} + +func TestLiveThreadService_ContributorsAndInvited(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/live-thread/contributors-and-invited.json") + require.NoError(t, err) + + mux.HandleFunc("/live/id123/contributors", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + contributors, _, err := client.LiveThread.Contributors(ctx, "id123") + require.NoError(t, err) + require.Equal(t, expectedLiveThreadContributorsAndInvited, contributors) +} + +func TestLiveThreadService_Accept(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/accept_contributor_invite", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Accept(ctx, "id123") + require.NoError(t, err) +} + +func TestLiveThreadService_Leave(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/leave_contributor", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Leave(ctx, "id123") + require.NoError(t, err) +} + +func TestLiveThreadService_Invite(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/invite_contributor", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", "+all") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Invite(ctx, "id123", "testuser", nil) + require.NoError(t, err) +} + +func TestLiveThreadService_Invite_Permissions(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/invite_contributor", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", "-all,+close,-discussions,-edit,+manage,-settings,+update") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Invite(ctx, "id123", "testuser", &LiveThreadPermissions{Close: true, Manage: true, Update: true}) + require.NoError(t, err) +} + +func TestLiveThreadService_Uninvite(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/rm_contributor_invite", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t2_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Uninvite(ctx, "id123", "t2_test") + require.NoError(t, err) +} + +func TestLiveThreadService_SetPermissions(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/set_contributor_permissions", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "liveupdate_contributor") + form.Set("permissions", "-all,-close,+discussions,+edit,-manage,+settings,-update") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.SetPermissions(ctx, "id123", "testuser", &LiveThreadPermissions{Discussions: true, Edit: true, Settings: true}) + require.NoError(t, err) +} + +func TestLiveThreadService_SetPermissionsForInvite(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/set_contributor_permissions", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "liveupdate_contributor_invite") + form.Set("permissions", "-all,-close,+discussions,+edit,-manage,+settings,-update") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.SetPermissionsForInvite(ctx, "id123", "testuser", &LiveThreadPermissions{Discussions: true, Edit: true, Settings: true}) + require.NoError(t, err) +} + +func TestLiveThreadService_Revoke(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/rm_contributor", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t2_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Revoke(ctx, "id123", "t2_test") + require.NoError(t, err) +} + +func TestLiveThreadService_HideDiscussion(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/hide_discussion", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("link", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.HideDiscussion(ctx, "id123", "t3_test") + require.NoError(t, err) +} + +func TestLiveThreadService_UnhideDiscussion(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/unhide_discussion", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("link", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.UnhideDiscussion(ctx, "id123", "t3_test") + require.NoError(t, err) +} + +func TestLiveThreadService_Report(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/live/id123/report", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("type", "spam") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.LiveThread.Report(ctx, "id123", "invalidreason") + require.EqualError(t, err, "invalid reason for reporting live thread: invalidreason") + + _, err = client.LiveThread.Report(ctx, "id123", "spam") + require.NoError(t, err) +} diff --git a/v2/reddit/message.go b/v2/reddit/message.go new file mode 100644 index 0000000..c4fc86c --- /dev/null +++ b/v2/reddit/message.go @@ -0,0 +1,294 @@ +package reddit + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/google/go-querystring/query" +) + +// MessageService handles communication with the message +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_messages +type MessageService struct { + client *Client +} + +// Message is a message. +type Message struct { + ID string `json:"id"` + FullID string `json:"name"` + Created *Timestamp `json:"created_utc"` + + Subject string `json:"subject"` + Text string `json:"body"` + ParentID string `json:"parent_id"` + + Author string `json:"author"` + To string `json:"dest"` + + IsComment bool `json:"was_comment"` +} + +type inboxThing struct { + Kind string `json:"kind"` + Data *Message `json:"data"` +} + +type inboxListing struct { + inboxThings + after string +} + +func (l *inboxListing) After() string { + return l.after +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (l *inboxListing) UnmarshalJSON(b []byte) error { + root := new(struct { + Data struct { + Things inboxThings `json:"children"` + After string `json:"after"` + } `json:"data"` + }) + + err := json.Unmarshal(b, root) + if err != nil { + return err + } + + l.inboxThings = root.Data.Things + l.after = root.Data.After + + return nil +} + +type inboxThings struct { + Comments []*Message + Messages []*Message +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (t *inboxThings) UnmarshalJSON(b []byte) error { + var things []inboxThing + if err := json.Unmarshal(b, &things); err != nil { + return err + } + + t.add(things...) + return nil +} + +func (t *inboxThings) add(things ...inboxThing) { + for _, thing := range things { + switch thing.Kind { + case kindComment: + t.Comments = append(t.Comments, thing.Data) + case kindMessage: + t.Messages = append(t.Messages, thing.Data) + } + } +} + +// SendMessageRequest represents a request to send a message. +type SendMessageRequest struct { + // Username, or /r/name for that subreddit's moderators. + To string `url:"to"` + Subject string `url:"subject"` + Text string `url:"text"` + // Optional. If specified, the message will look like it came from the subreddit. + FromSubreddit string `url:"from_sr,omitempty"` +} + +// ReadAll marks all messages/comments as read. It queues up the task on Reddit's end. +// A successful response returns 202 to acknowledge acceptance of the request. +// This endpoint is heavily rate limited. +func (s *MessageService) ReadAll(ctx context.Context) (*Response, error) { + path := "api/read_all_messages" + req, err := s.client.NewRequest(http.MethodPost, path, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +// Read marks a message/comment as read via its full ID. +func (s *MessageService) Read(ctx context.Context, ids ...string) (*Response, error) { + if len(ids) == 0 { + return nil, errors.New("must provide at least 1 id") + } + + path := "api/read_message" + + form := url.Values{} + form.Set("id", strings.Join(ids, ",")) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Unread marks a message/comment as unread via its full ID. +func (s *MessageService) Unread(ctx context.Context, ids ...string) (*Response, error) { + if len(ids) == 0 { + return nil, errors.New("must provide at least 1 id") + } + + path := "api/unread_message" + + form := url.Values{} + form.Set("id", strings.Join(ids, ",")) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Block the author of a post, comment or message via its full ID. +func (s *MessageService) Block(ctx context.Context, id string) (*Response, error) { + path := "api/block" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Collapse messages. +func (s *MessageService) Collapse(ctx context.Context, ids ...string) (*Response, error) { + if len(ids) == 0 { + return nil, errors.New("must provide at least 1 id") + } + + path := "api/collapse_message" + + form := url.Values{} + form.Set("id", strings.Join(ids, ",")) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Uncollapse messages. +func (s *MessageService) Uncollapse(ctx context.Context, ids ...string) (*Response, error) { + if len(ids) == 0 { + return nil, errors.New("must provide at least 1 id") + } + + path := "api/uncollapse_message" + + form := url.Values{} + form.Set("id", strings.Join(ids, ",")) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Delete a message. +func (s *MessageService) Delete(ctx context.Context, id string) (*Response, error) { + path := "api/del_msg" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Send a message. +func (s *MessageService) Send(ctx context.Context, sendRequest *SendMessageRequest) (*Response, error) { + if sendRequest == nil { + return nil, errors.New("*SendMessageRequest: cannot be nil") + } + + path := "api/compose" + + form, err := query.Values(sendRequest) + if err != nil { + return nil, err + } + form.Set("api_type", "json") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Inbox returns comments and messages that appear in your inbox, respectively. +func (s *MessageService) Inbox(ctx context.Context, opts *ListOptions) ([]*Message, []*Message, *Response, error) { + root, resp, err := s.inbox(ctx, "message/inbox", opts) + if err != nil { + return nil, nil, resp, err + } + return root.Comments, root.Messages, resp, nil +} + +// InboxUnread returns unread comments and messages that appear in your inbox, respectively. +func (s *MessageService) InboxUnread(ctx context.Context, opts *ListOptions) ([]*Message, []*Message, *Response, error) { + root, resp, err := s.inbox(ctx, "message/unread", opts) + if err != nil { + return nil, nil, resp, err + } + return root.Comments, root.Messages, resp, nil +} + +// Sent returns messages that you've sent. +func (s *MessageService) Sent(ctx context.Context, opts *ListOptions) ([]*Message, *Response, error) { + root, resp, err := s.inbox(ctx, "message/sent", opts) + if err != nil { + return nil, resp, err + } + return root.Messages, resp, nil +} + +func (s *MessageService) inbox(ctx context.Context, path string, opts *ListOptions) (*inboxListing, *Response, error) { + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(inboxListing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, nil, err + } + + return root, resp, nil +} diff --git a/v2/reddit/message_test.go b/v2/reddit/message_test.go new file mode 100644 index 0000000..b624653 --- /dev/null +++ b/v2/reddit/message_test.go @@ -0,0 +1,258 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedCommentMessages = []*Message{ + { + ID: "g1xi2m9", + FullID: "t1_g1xi2m9", + Created: &Timestamp{time.Date(2020, 8, 18, 0, 24, 13, 0, time.UTC)}, + + Subject: "post reply", + Text: "u/testuser2 hello", + ParentID: "t3_hs03f3", + + Author: "testuser1", + To: "testuser2", + + IsComment: true, + }, +} + +var expectedMessages = []*Message{ + { + ID: "qwki97", + FullID: "t4_qwki97", + Created: &Timestamp{time.Date(2020, 8, 18, 0, 16, 53, 0, time.UTC)}, + + Subject: "re: test", + Text: "test", + ParentID: "t4_qwki4m", + + Author: "testuser1", + To: "testuser2", + + IsComment: false, + }, +} + +func TestMessageService_ReadAll(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/read_all_messages", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusAccepted) + }) + + resp, err := client.Message.ReadAll(ctx) + require.NoError(t, err) + require.Equal(t, http.StatusAccepted, resp.StatusCode) +} + +func TestMessageService_Read(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/read_message", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "test1,test2,test3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Message.Read(ctx) + require.EqualError(t, err, "must provide at least 1 id") + + _, err = client.Message.Read(ctx, "test1", "test2", "test3") + require.NoError(t, err) +} + +func TestMessageService_Unread(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/unread_message", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "test1,test2,test3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Message.Unread(ctx) + require.EqualError(t, err, "must provide at least 1 id") + + _, err = client.Message.Unread(ctx, "test1", "test2", "test3") + require.NoError(t, err) +} + +func TestMessageService_Block(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/block", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Message.Block(ctx, "test") + require.NoError(t, err) +} + +func TestMessageService_Collapse(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/collapse_message", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "test1,test2,test3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Message.Collapse(ctx) + require.EqualError(t, err, "must provide at least 1 id") + + _, err = client.Message.Collapse(ctx, "test1", "test2", "test3") + require.NoError(t, err) +} + +func TestMessageService_Uncollapse(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/uncollapse_message", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "test1,test2,test3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Message.Uncollapse(ctx) + require.EqualError(t, err, "must provide at least 1 id") + + _, err = client.Message.Uncollapse(ctx, "test1", "test2", "test3") + require.NoError(t, err) +} + +func TestMessageService_Delete(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/del_msg", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Message.Delete(ctx, "test") + require.NoError(t, err) +} + +func TestMessageService_Send(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/compose", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("to", "test") + form.Set("subject", "test subject") + form.Set("text", "test text") + form.Set("from_sr", "hello world") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Message.Send(ctx, nil) + require.EqualError(t, err, "*SendMessageRequest: cannot be nil") + + _, err = client.Message.Send(ctx, &SendMessageRequest{ + To: "test", + Subject: "test subject", + Text: "test text", + FromSubreddit: "hello world", + }) + require.NoError(t, err) +} + +func TestMessageService_Inbox(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/message/inbox.json") + require.NoError(t, err) + + mux.HandleFunc("/message/inbox", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + comments, messages, _, err := client.Message.Inbox(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedCommentMessages, comments) + require.Equal(t, expectedMessages, messages) +} + +func TestMessageService_InboxUnread(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/message/inbox.json") + require.NoError(t, err) + + mux.HandleFunc("/message/unread", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + comments, messages, _, err := client.Message.InboxUnread(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedCommentMessages, comments) + require.Equal(t, expectedMessages, messages) +} + +func TestMessageService_Sent(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/message/inbox.json") + require.NoError(t, err) + + mux.HandleFunc("/message/sent", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + messages, _, err := client.Message.Sent(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedMessages, messages) +} diff --git a/v2/reddit/moderation.go b/v2/reddit/moderation.go new file mode 100644 index 0000000..a1af7d2 --- /dev/null +++ b/v2/reddit/moderation.go @@ -0,0 +1,494 @@ +package reddit + +import ( + "context" + "fmt" + "net/http" + "net/url" + "reflect" + + "github.com/google/go-querystring/query" +) + +// ModerationService handles communication with the moderation +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_moderation +type ModerationService struct { + client *Client +} + +// ModAction is an action executed by a moderator of a subreddit, such +// as inviting another user to be a mod, or setting permissions. +type ModAction struct { + ID string `json:"id,omitempty"` + Action string `json:"action,omitempty"` + Created *Timestamp `json:"created_utc,omitempty"` + + Moderator string `json:"mod,omitempty"` + // Not the full ID, just the ID36. + ModeratorID string `json:"mod_id36,omitempty"` + + // The author of whatever the action was produced on, e.g. a user, post, comment, etc. + TargetAuthor string `json:"target_author,omitempty"` + // This is the full ID of whatever the target was. + TargetID string `json:"target_fullname,omitempty"` + TargetTitle string `json:"target_title,omitempty"` + TargetPermalink string `json:"target_permalink,omitempty"` + TargetBody string `json:"target_body,omitempty"` + + Subreddit string `json:"subreddit,omitempty"` + // Not the full ID, just the ID36. + SubredditID string `json:"sr_id36,omitempty"` +} + +// ModPermissions are the different permissions moderators have or don't have on a subreddit. +// Read about them here: https://mods.reddithelp.com/hc/en-us/articles/360009381491-User-Management-moderators-and-permissions +type ModPermissions struct { + All bool `permission:"all"` + Access bool `permission:"access"` + ChatConfig bool `permission:"chat_config"` + ChatOperator bool `permission:"chat_operator"` + Config bool `permission:"config"` + Flair bool `permission:"flair"` + Mail bool `permission:"mail"` + Posts bool `permission:"posts"` + Wiki bool `permission:"wiki"` +} + +func (p *ModPermissions) String() (s string) { + if p == nil { + return "+all" + } + + t := reflect.TypeOf(*p) + v := reflect.ValueOf(*p) + + for i := 0; i < t.NumField(); i++ { + if v.Field(i).Kind() != reflect.Bool { + continue + } + + permission := t.Field(i).Tag.Get("permission") + permitted := v.Field(i).Bool() + + if permitted { + s += "+" + } else { + s += "-" + } + + s += permission + + if i != t.NumField()-1 { + s += "," + } + } + + return +} + +// BanConfig configures the ban of the user being banned. +type BanConfig struct { + Reason string `url:"reason,omitempty"` + // Not visible to the user being banned. + ModNote string `url:"note,omitempty"` + // How long the ban will last. 0-999. Leave nil for permanent. + Days *int `url:"duration,omitempty"` + // Note to include in the ban message to the user. + Message string `url:"ban_message,omitempty"` +} + +// Actions gets a list of moderator actions on a subreddit. +func (s *ModerationService) Actions(ctx context.Context, subreddit string, opts *ListModActionOptions) ([]*ModAction, *Response, error) { + path := fmt.Sprintf("r/%s/about/log", subreddit) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.ModActions(), resp, nil +} + +// AcceptInvite accepts a pending invite to moderate the specified subreddit. +func (s *ModerationService) AcceptInvite(ctx context.Context, subreddit string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/accept_moderator_invite", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Approve a post or comment via its full ID. +func (s *ModerationService) Approve(ctx context.Context, id string) (*Response, error) { + path := "api/approve" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Remove a post, comment or modmail message via its full ID. +func (s *ModerationService) Remove(ctx context.Context, id string) (*Response, error) { + path := "api/remove" + + form := url.Values{} + form.Set("id", id) + form.Set("spam", "false") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// RemoveSpam removes a post, comment or modmail message via its full ID and marks it as spam. +func (s *ModerationService) RemoveSpam(ctx context.Context, id string) (*Response, error) { + path := "api/remove" + + form := url.Values{} + form.Set("id", id) + form.Set("spam", "true") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Leave abdicates your moderator status in a subreddit via its full ID. +func (s *ModerationService) Leave(ctx context.Context, subredditID string) (*Response, error) { + path := "api/leavemoderator" + + form := url.Values{} + form.Set("id", subredditID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// LeaveContributor abdicates your approved user status in a subreddit via its full ID. +func (s *ModerationService) LeaveContributor(ctx context.Context, subredditID string) (*Response, error) { + path := "api/leavecontributor" + + form := url.Values{} + form.Set("id", subredditID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Reported returns posts and comments that have been reported. +func (s *ModerationService) Reported(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, []*Comment, *Response, error) { + path := fmt.Sprintf("r/%s/about/reports", subreddit) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, nil, resp, err + } + return l.Posts(), l.Comments(), resp, nil +} + +// Spam returns posts and comments marked as spam. +func (s *ModerationService) Spam(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, []*Comment, *Response, error) { + path := fmt.Sprintf("r/%s/about/spam", subreddit) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, nil, resp, err + } + return l.Posts(), l.Comments(), resp, nil +} + +// Queue returns posts and comments requiring moderator reviews, such as one that have been +// reported or caught in the spam filter. +func (s *ModerationService) Queue(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, []*Comment, *Response, error) { + path := fmt.Sprintf("r/%s/about/modqueue", subreddit) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, nil, resp, err + } + return l.Posts(), l.Comments(), resp, nil +} + +// Unmoderated returns posts that have yet to be approved/removed by a mod. +func (s *ModerationService) Unmoderated(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, *Response, error) { + path := fmt.Sprintf("r/%s/about/unmoderated", subreddit) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} + +// Edited gets posts and comments that have been edited recently. +func (s *ModerationService) Edited(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, []*Comment, *Response, error) { + path := fmt.Sprintf("r/%s/about/edited", subreddit) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, nil, resp, err + } + return l.Posts(), l.Comments(), resp, nil +} + +// IgnoreReports prevents reports on a post or comment from causing notifications. +func (s *ModerationService) IgnoreReports(ctx context.Context, id string) (*Response, error) { + path := "api/ignore_reports" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UnignoreReports allows reports on a post or comment to cause notifications. +func (s *ModerationService) UnignoreReports(ctx context.Context, id string) (*Response, error) { + path := "api/unignore_reports" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Invite a user to become a moderator of the subreddit. +// If permissions is nil, all permissions will be granted. +func (s *ModerationService) Invite(ctx context.Context, subreddit string, username string, permissions *ModPermissions) (*Response, error) { + path := fmt.Sprintf("r/%s/api/friend", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", "moderator_invite") + form.Set("permissions", permissions.String()) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Uninvite a user from becoming a moderator of the subreddit. +func (s *ModerationService) Uninvite(ctx context.Context, subreddit string, username string) (*Response, error) { + return s.deleteRelationship(ctx, subreddit, username, "moderator_invite") +} + +// SetPermissions sets the mod permissions for the user in the subreddit. +// If permissions is nil, all permissions will be granted. +func (s *ModerationService) SetPermissions(ctx context.Context, subreddit string, username string, permissions *ModPermissions) (*Response, error) { + path := fmt.Sprintf("r/%s/api/setpermissions", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", "moderator_invite") + form.Set("permissions", permissions.String()) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Ban a user from the subreddit. +func (s *ModerationService) Ban(ctx context.Context, subreddit string, username string, config *BanConfig) (*Response, error) { + path := fmt.Sprintf("r/%s/api/friend", subreddit) + + form, err := query.Values(config) + if err != nil { + return nil, err + } + + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", "banned") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Unban a user from the subreddit. +func (s *ModerationService) Unban(ctx context.Context, subreddit string, username string) (*Response, error) { + return s.deleteRelationship(ctx, subreddit, username, "banned") +} + +// BanWiki bans a user from contributing to the subreddit wiki. +func (s *ModerationService) BanWiki(ctx context.Context, subreddit string, username string, config *BanConfig) (*Response, error) { + path := fmt.Sprintf("r/%s/api/friend", subreddit) + + form, err := query.Values(config) + if err != nil { + return nil, err + } + + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", "wikibanned") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UnbanWiki unbans a user from contributing to the subreddit wiki. +func (s *ModerationService) UnbanWiki(ctx context.Context, subreddit string, username string) (*Response, error) { + return s.deleteRelationship(ctx, subreddit, username, "wikibanned") +} + +// Mute a user in the subreddit. +func (s *ModerationService) Mute(ctx context.Context, subreddit string, username string) (*Response, error) { + return s.createRelationship(ctx, subreddit, username, "muted") +} + +// Unmute a user in the subreddit. +func (s *ModerationService) Unmute(ctx context.Context, subreddit string, username string) (*Response, error) { + return s.deleteRelationship(ctx, subreddit, username, "muted") +} + +// ApproveUser adds a user as an approved user to the subreddit. +func (s *ModerationService) ApproveUser(ctx context.Context, subreddit string, username string) (*Response, error) { + return s.createRelationship(ctx, subreddit, username, "contributor") +} + +// UnapproveUser removes a user as an approved user to the subreddit. +func (s *ModerationService) UnapproveUser(ctx context.Context, subreddit string, username string) (*Response, error) { + return s.deleteRelationship(ctx, subreddit, username, "contributor") +} + +// ApproveUserWiki adds a user as an approved wiki contributor in the subreddit. +func (s *ModerationService) ApproveUserWiki(ctx context.Context, subreddit string, username string) (*Response, error) { + return s.createRelationship(ctx, subreddit, username, "wikicontributor") +} + +// UnapproveUserWiki removes a user as an approved wiki contributor in the subreddit. +func (s *ModerationService) UnapproveUserWiki(ctx context.Context, subreddit string, username string) (*Response, error) { + return s.deleteRelationship(ctx, subreddit, username, "wikicontributor") +} + +func (s *ModerationService) createRelationship(ctx context.Context, subreddit, username, relationship string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/friend", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", relationship) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +func (s *ModerationService) deleteRelationship(ctx context.Context, subreddit, username, relationship string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/unfriend", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", username) + form.Set("type", relationship) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Distinguish your post or comment via its full ID, adding a moderator tag to it. +// todo: add how=admin and how=special? They require special privileges. +func (s *ModerationService) Distinguish(ctx context.Context, id string) (*Response, error) { + path := "api/distinguish" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("how", "yes") + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// DistinguishAndSticky your comment via its full ID, adding a moderator tag to it +// and stickying the comment at the top of the thread. +func (s *ModerationService) DistinguishAndSticky(ctx context.Context, id string) (*Response, error) { + path := "api/distinguish" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("how", "yes") + form.Set("sticky", "true") + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Undistinguish your post or comment via its full ID, removing the moderator tag from it. +func (s *ModerationService) Undistinguish(ctx context.Context, id string) (*Response, error) { + path := "api/distinguish" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("how", "no") + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/moderation_test.go b/v2/reddit/moderation_test.go new file mode 100644 index 0000000..0966be8 --- /dev/null +++ b/v2/reddit/moderation_test.go @@ -0,0 +1,701 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedModActions = []*ModAction{ + { + ID: "ModAction_b4e7979a-c4ad-11ea-8440-0ea1b7c2b8f9", + Action: "spamcomment", + Created: &Timestamp{time.Date(2020, 7, 13, 2, 8, 14, 0, time.UTC)}, + + Moderator: "v_95", + ModeratorID: "164ab8", + + TargetAuthor: "testuser", + TargetID: "t1_fxw10aa", + TargetPermalink: "/r/helloworldtestt/comments/hq6r3t/yo/fxw10aa/", + TargetBody: "hi", + + Subreddit: "helloworldtestt", + SubredditID: "2uquw1", + }, + { + ID: "ModAction_a0408162-c4ad-11ea-8239-0e3b48262e8b", + Action: "sticky", + Created: &Timestamp{time.Date(2020, 7, 13, 2, 7, 38, 0, time.UTC)}, + + Moderator: "v_95", + ModeratorID: "164ab8", + + TargetAuthor: "testuser", + TargetID: "t3_hq6r3t", + TargetTitle: "yo", + TargetPermalink: "/r/helloworldtestt/comments/hq6r3t/yo/", + + Subreddit: "helloworldtestt", + SubredditID: "2uquw1", + }, +} + +func TestModerationService_Actions(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/moderation/actions.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/log", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("type", "testtype") + form.Set("mod", "testmod") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + modActions, resp, err := client.Moderation.Actions(ctx, "testsubreddit", &ListModActionOptions{Type: "testtype", Moderator: "testmod"}) + require.NoError(t, err) + require.Equal(t, expectedModActions, modActions) + require.Equal(t, "ModAction_a0408162-c4ad-11ea-8239-0e3b48262e8b", resp.After) +} + +func TestModerationService_AcceptInvite(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/moderation/actions.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/accept_moderator_invite", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, err = client.Moderation.AcceptInvite(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestModerationService_Approve(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/approve", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Approve(ctx, "t3_test") + require.NoError(t, err) +} + +func TestModerationService_Remove(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/remove", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("spam", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Remove(ctx, "t3_test") + require.NoError(t, err) +} + +func TestModerationService_RemoveSpam(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/remove", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("spam", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.RemoveSpam(ctx, "t3_test") + require.NoError(t, err) +} + +func TestModerationService_Leave(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/leavemoderator", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t5_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Leave(ctx, "t5_test") + require.NoError(t, err) +} + +func TestModerationService_LeaveContributor(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/leavecontributor", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t5_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.LeaveContributor(ctx, "t5_test") + require.NoError(t, err) +} + +func TestModerationService_Reported(t *testing.T) { + client, mux := setup(t) + + // contains posts and comments + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/reports", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, comments, resp, err := client.Moderation.Reported(ctx, "testsubreddit", nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t1_f0zsa37", resp.After) + + require.Len(t, comments, 1) + require.Equal(t, expectedComment, comments[0]) + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestModerationService_Spam(t *testing.T) { + client, mux := setup(t) + + // contains posts and comments + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/spam", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, comments, resp, err := client.Moderation.Spam(ctx, "testsubreddit", nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t1_f0zsa37", resp.After) + + require.Len(t, comments, 1) + require.Equal(t, expectedComment, comments[0]) + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestModerationService_Queue(t *testing.T) { + client, mux := setup(t) + + // contains posts and comments + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/modqueue", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, comments, resp, err := client.Moderation.Queue(ctx, "testsubreddit", nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t1_f0zsa37", resp.After) + + require.Len(t, comments, 1) + require.Equal(t, expectedComment, comments[0]) + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestModerationService_Unmoderated(t *testing.T) { + client, mux := setup(t) + + // contains posts and comments + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/unmoderated", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.Moderation.Unmoderated(ctx, "testsubreddit", nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestModerationService_Edited(t *testing.T) { + client, mux := setup(t) + + // contains posts and comments + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/edited", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, comments, resp, err := client.Moderation.Edited(ctx, "testsubreddit", nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t1_f0zsa37", resp.After) + + require.Len(t, comments, 1) + require.Equal(t, expectedComment, comments[0]) + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestModerationService_IgnoreReports(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/ignore_reports", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.IgnoreReports(ctx, "t3_test") + require.NoError(t, err) +} + +func TestModerationService_UnignoreReports(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/unignore_reports", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.UnignoreReports(ctx, "t3_test") + require.NoError(t, err) +} + +func TestModerationService_Invite(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/friend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "moderator_invite") + form.Set("permissions", "+all") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Invite(ctx, "testsubreddit", "testuser", nil) + require.NoError(t, err) +} + +func TestModerationService_Invite_Permissions(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/friend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "moderator_invite") + form.Set("permissions", "-all,-access,-chat_config,+chat_operator,+config,-flair,-mail,-posts,+wiki") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Invite(ctx, "testsubreddit", "testuser", &ModPermissions{ChatOperator: true, Config: true, Wiki: true}) + require.NoError(t, err) +} + +func TestModerationService_Uninvite(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/unfriend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "moderator_invite") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Uninvite(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestModerationService_SetPermissions(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/setpermissions", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "moderator_invite") + form.Set("permissions", "-all,+access,-chat_config,-chat_operator,-config,+flair,-mail,+posts,-wiki") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.SetPermissions(ctx, "testsubreddit", "testuser", &ModPermissions{Access: true, Flair: true, Posts: true}) + require.NoError(t, err) +} + +func TestModerationService_Ban(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/friend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "banned") + form.Set("reason", "test reason") + form.Set("note", "test mod note") + form.Set("duration", "5") + form.Set("ban_message", "test message") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Ban(ctx, "testsubreddit", "testuser", &BanConfig{ + Reason: "test reason", + ModNote: "test mod note", + Days: Int(5), + Message: "test message", + }) + require.NoError(t, err) +} + +func TestModerationService_Unban(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/unfriend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "banned") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Unban(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestModerationService_BanWiki(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/friend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "wikibanned") + form.Set("reason", "test reason") + form.Set("note", "test mod note") + form.Set("duration", "5") + form.Set("ban_message", "test message") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.BanWiki(ctx, "testsubreddit", "testuser", &BanConfig{ + Reason: "test reason", + ModNote: "test mod note", + Days: Int(5), + Message: "test message", + }) + require.NoError(t, err) +} + +func TestModerationService_UnbanWiki(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/unfriend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "wikibanned") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.UnbanWiki(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestModerationService_Mute(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/friend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "muted") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Mute(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestModerationService_Unmuted(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/unfriend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "muted") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Unmute(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestModerationService_ApproveUser(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/friend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "contributor") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.ApproveUser(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestModerationService_UnapproveUser(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/unfriend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "contributor") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.UnapproveUser(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestModerationService_ApproveUserWiki(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/friend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "wikicontributor") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.ApproveUserWiki(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestModerationService_UnapproveUserWiki(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/unfriend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testuser") + form.Set("type", "wikicontributor") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.UnapproveUserWiki(ctx, "testsubreddit", "testuser") + require.NoError(t, err) +} + +func TestModerationService_Distinguish(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/distinguish", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("how", "yes") + form.Set("id", "t1_123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Distinguish(ctx, "t1_123") + require.NoError(t, err) +} + +func TestModerationService_DistinguishAndSticky(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/distinguish", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("how", "yes") + form.Set("sticky", "true") + form.Set("id", "t1_123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.DistinguishAndSticky(ctx, "t1_123") + require.NoError(t, err) +} + +func TestModerationService_Undistinguish(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/distinguish", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("how", "no") + form.Set("id", "t1_123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Moderation.Undistinguish(ctx, "t1_123") + require.NoError(t, err) +} diff --git a/v2/reddit/multi.go b/v2/reddit/multi.go new file mode 100644 index 0000000..6e7c3a8 --- /dev/null +++ b/v2/reddit/multi.go @@ -0,0 +1,307 @@ +package reddit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/google/go-querystring/query" +) + +// MultiService handles communication with the multireddit +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api#section_multis +type MultiService struct { + client *Client +} + +// Multi is a multireddit, i.e. a customizable group of subreddits. +// Users can create multis for custom navigation, instead of browsing +// one subreddit or all subreddits at a time. +type Multi struct { + Name string `json:"name,omitempty"` + DisplayName string `json:"display_name,omitempty"` + // Format: user/{username}/m/{multiname} + Path string `json:"path,omitempty"` + Description string `json:"description_md,omitempty"` + Subreddits SubredditNames `json:"subreddits"` + CopiedFrom *string `json:"copied_from"` + + Owner string `json:"owner,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + Created *Timestamp `json:"created_utc,omitempty"` + + NumberOfSubscribers int `json:"num_subscribers"` + Visibility string `json:"visibility,omitempty"` + Subscribed bool `json:"is_subscriber"` + Favorite bool `json:"is_favorited"` + CanEdit bool `json:"can_edit"` + NSFW bool `json:"over_18"` +} + +// SubredditNames is a list of subreddit names. +type SubredditNames []string + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (n *SubredditNames) UnmarshalJSON(data []byte) error { + type subreddit struct { + Name string `json:"name"` + } + var subreddits []subreddit + + err := json.Unmarshal(data, &subreddits) + if err != nil { + return err + } + + *n = make(SubredditNames, len(subreddits)) + for i, sr := range subreddits { + (*n)[i] = sr.Name + } + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +func (n *SubredditNames) MarshalJSON() ([]byte, error) { + type subreddit struct { + Name string `json:"name"` + } + + subreddits := make([]subreddit, 0) + for _, name := range *n { + subreddits = append(subreddits, subreddit{name}) + } + + return json.Marshal(subreddits) +} + +// MultiCopyRequest represents a request to copy a multireddit. +type MultiCopyRequest struct { + FromPath string `url:"from"` + ToPath string `url:"to"` + // Raw markdown text. + Description string `url:"description_md,omitempty"` + // No longer than 50 characters. + DisplayName string `url:"display_name,omitempty"` +} + +// MultiCreateOrUpdateRequest represents a request to create/update a multireddit. +type MultiCreateOrUpdateRequest struct { + // For updates, this is the display name, i.e. the header of the multi. + // Not part of the path necessarily. + Name string `json:"display_name,omitempty"` + Description string `json:"description_md,omitempty"` + Subreddits SubredditNames `json:"subreddits,omitempty"` + // One of: private, public, hidden. + Visibility string `json:"visibility,omitempty"` +} + +type rootMultiDescription struct { + Body string `json:"body_md"` +} + +// Get the multireddit from its url path. +func (s *MultiService) Get(ctx context.Context, multiPath string) (*Multi, *Response, error) { + path := fmt.Sprintf("api/multi/%s", multiPath) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + multi, _ := t.Multi() + return multi, resp, nil +} + +// Mine returns your multireddits. +func (s *MultiService) Mine(ctx context.Context) ([]*Multi, *Response, error) { + path := "api/multi/mine" + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(things) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Multis, resp, nil +} + +// Of returns the user's public multireddits. +// Or, if the user is you, all of your multireddits. +func (s *MultiService) Of(ctx context.Context, username string) ([]*Multi, *Response, error) { + path := fmt.Sprintf("api/multi/user/%s", username) + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(things) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Multis, resp, nil +} + +// Copy a multireddit. +func (s *MultiService) Copy(ctx context.Context, copyRequest *MultiCopyRequest) (*Multi, *Response, error) { + if copyRequest == nil { + return nil, nil, errors.New("*MultiCopyRequest: cannot be nil") + } + + path := "api/multi/copy" + form, err := query.Values(copyRequest) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(thing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + multi, _ := root.Multi() + return multi, resp, nil +} + +// Create a multireddit. +func (s *MultiService) Create(ctx context.Context, createRequest *MultiCreateOrUpdateRequest) (*Multi, *Response, error) { + if createRequest == nil { + return nil, nil, errors.New("*MultiCreateOrUpdateRequest: cannot be nil") + } + + byteValue, err := json.Marshal(createRequest) + if err != nil { + return nil, nil, err + } + + form := url.Values{} + form.Set("model", string(byteValue)) + + path := "api/multi" + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(thing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + multi, _ := root.Multi() + return multi, resp, nil +} + +// Update a multireddit. +// If the multireddit does not exist, it will be created. +func (s *MultiService) Update(ctx context.Context, multiPath string, updateRequest *MultiCreateOrUpdateRequest) (*Multi, *Response, error) { + if updateRequest == nil { + return nil, nil, errors.New("*MultiCreateOrUpdateRequest: cannot be nil") + } + + byteValue, err := json.Marshal(updateRequest) + if err != nil { + return nil, nil, err + } + + form := url.Values{} + form.Set("model", string(byteValue)) + + path := fmt.Sprintf("api/multi/%s", multiPath) + req, err := s.client.NewRequest(http.MethodPut, path, form) + if err != nil { + return nil, nil, err + } + + root := new(thing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + multi, _ := root.Multi() + return multi, resp, nil +} + +// Delete a multireddit. +func (s *MultiService) Delete(ctx context.Context, multiPath string) (*Response, error) { + path := fmt.Sprintf("api/multi/%s", multiPath) + req, err := s.client.NewRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +// Description gets a multireddit's description. +func (s *MultiService) Description(ctx context.Context, multiPath string) (string, *Response, error) { + path := fmt.Sprintf("api/multi/%s/description", multiPath) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return "", resp, err + } + multiDescription, _ := t.MultiDescription() + return multiDescription, resp, nil +} + +// UpdateDescription updates a multireddit's description. +func (s *MultiService) UpdateDescription(ctx context.Context, multiPath string, description string) (string, *Response, error) { + form := url.Values{} + form.Set("model", fmt.Sprintf(`{"body_md":"%s"}`, description)) + + path := fmt.Sprintf("api/multi/%s/description", multiPath) + req, err := s.client.NewRequest(http.MethodPut, path, form) + if err != nil { + return "", nil, err + } + + root := new(thing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return "", resp, err + } + + multiDescription, _ := root.MultiDescription() + return multiDescription, resp, nil +} + +// AddSubreddit adds a subreddit to a multireddit. +func (s *MultiService) AddSubreddit(ctx context.Context, multiPath string, subreddit string) (*Response, error) { + path := fmt.Sprintf("api/multi/%s/r/%s", multiPath, subreddit) + + form := url.Values{} + form.Set("model", fmt.Sprintf(`{"name":"%s"}`, subreddit)) + + req, err := s.client.NewRequest(http.MethodPut, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// DeleteSubreddit removes a subreddit from a multireddit. +func (s *MultiService) DeleteSubreddit(ctx context.Context, multiPath string, subreddit string) (*Response, error) { + path := fmt.Sprintf("api/multi/%s/r/%s", multiPath, subreddit) + req, err := s.client.NewRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/multi_test.go b/v2/reddit/multi_test.go new file mode 100644 index 0000000..72423b6 --- /dev/null +++ b/v2/reddit/multi_test.go @@ -0,0 +1,280 @@ +package reddit + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedMulti = &Multi{ + Name: "test", + DisplayName: "test", + Path: "/user/v_95/m/test/", + Subreddits: []string{"nba", "golang"}, + CopiedFrom: nil, + + Owner: "v_95", + OwnerID: "t2_164ab8", + Created: &Timestamp{time.Date(2020, 7, 11, 4, 55, 12, 0, time.UTC)}, + + NumberOfSubscribers: 0, + Visibility: "private", + CanEdit: true, +} + +var expectedMulti2 = &Multi{ + Name: "test2", + DisplayName: "test2", + Path: "/user/v_95/m/test2/", + Subreddits: []string{"redditdev", "test"}, + CopiedFrom: nil, + + Owner: "v_95", + OwnerID: "t2_164ab8", + Created: &Timestamp{time.Date(2020, 7, 11, 4, 57, 3, 0, time.UTC)}, + + NumberOfSubscribers: 0, + Visibility: "private", + CanEdit: true, +} + +func TestMultiService_Get(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/multi/multi.json") + require.NoError(t, err) + + mux.HandleFunc("/api/multi/user/testuser/m/testmulti", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + multi, _, err := client.Multi.Get(ctx, "user/testuser/m/testmulti") + require.NoError(t, err) + require.Equal(t, expectedMulti, multi) +} + +func TestMultiService_Mine(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/multi/multis.json") + require.NoError(t, err) + + mux.HandleFunc("/api/multi/mine", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + multis, _, err := client.Multi.Mine(ctx) + require.NoError(t, err) + require.Equal(t, []*Multi{expectedMulti, expectedMulti2}, multis) +} + +func TestMultiService_Of(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/multi/multis.json") + require.NoError(t, err) + + mux.HandleFunc("/api/multi/user/test", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + multis, _, err := client.Multi.Of(ctx, "test") + require.NoError(t, err) + require.Equal(t, []*Multi{expectedMulti, expectedMulti2}, multis) +} + +func TestMultiService_Copy(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/multi/multi.json") + require.NoError(t, err) + + mux.HandleFunc("/api/multi/copy", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("from", "user/testuser/m/testmulti") + form.Set("to", "user/testuser2/m/testmulti2") + form.Set("description_md", "this is a multireddit") + form.Set("display_name", "hello") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.Multi.Copy(ctx, nil) + require.EqualError(t, err, "*MultiCopyRequest: cannot be nil") + + multi, _, err := client.Multi.Copy(ctx, &MultiCopyRequest{ + FromPath: "user/testuser/m/testmulti", + ToPath: "user/testuser2/m/testmulti2", + Description: "this is a multireddit", + DisplayName: "hello", + }) + require.NoError(t, err) + require.Equal(t, expectedMulti, multi) +} + +func TestMultiService_Create(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/multi/multi.json") + require.NoError(t, err) + + createRequest := &MultiCreateOrUpdateRequest{ + Name: "testmulti", + Description: "this is a multireddit", + Subreddits: []string{"golang"}, + Visibility: "public", + } + + mux.HandleFunc("/api/multi", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + err := r.ParseForm() + require.NoError(t, err) + + model := r.Form.Get("model") + + expectedCreateRequest := new(MultiCreateOrUpdateRequest) + err = json.Unmarshal([]byte(model), expectedCreateRequest) + require.NoError(t, err) + require.Equal(t, expectedCreateRequest, createRequest) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.Multi.Create(ctx, nil) + require.EqualError(t, err, "*MultiCreateOrUpdateRequest: cannot be nil") + + multi, _, err := client.Multi.Create(ctx, createRequest) + require.NoError(t, err) + require.Equal(t, expectedMulti, multi) +} + +func TestMultiService_Update(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/multi/multi.json") + require.NoError(t, err) + + updateRequest := &MultiCreateOrUpdateRequest{ + Name: "testmulti", + Description: "this is a multireddit", + Visibility: "public", + } + + mux.HandleFunc("/api/multi/user/testuser/m/testmulti", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPut, r.Method) + + err := r.ParseForm() + require.NoError(t, err) + + model := r.Form.Get("model") + + expectedCreateRequest := new(MultiCreateOrUpdateRequest) + err = json.Unmarshal([]byte(model), expectedCreateRequest) + require.NoError(t, err) + require.Equal(t, expectedCreateRequest, updateRequest) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.Multi.Update(ctx, "user/testuser/m/testmulti", nil) + require.EqualError(t, err, "*MultiCreateOrUpdateRequest: cannot be nil") + + multi, _, err := client.Multi.Update(ctx, "user/testuser/m/testmulti", updateRequest) + require.NoError(t, err) + require.Equal(t, expectedMulti, multi) +} + +func TestMultiService_Delete(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/multi/user/testuser/m/testmulti", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + }) + + _, err := client.Multi.Delete(ctx, "user/testuser/m/testmulti") + require.NoError(t, err) +} + +func TestMultiService_Description(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/multi/description.json") + require.NoError(t, err) + + mux.HandleFunc("/api/multi/user/testuser/m/testmulti/description", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + description, _, err := client.Multi.Description(ctx, "user/testuser/m/testmulti") + require.NoError(t, err) + require.Equal(t, "hello world", description) +} + +func TestMultiService_UpdateDescription(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/multi/description.json") + require.NoError(t, err) + + mux.HandleFunc("/api/multi/user/testuser/m/testmulti/description", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPut, r.Method) + + form := url.Values{} + form.Set("model", `{"body_md":"hello world"}`) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + description, _, err := client.Multi.UpdateDescription(ctx, "user/testuser/m/testmulti", "hello world") + require.NoError(t, err) + require.Equal(t, "hello world", description) +} + +func TestMultiService_AddSubreddit(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/multi/user/testuser/m/testmulti/r/golang", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPut, r.Method) + + form := url.Values{} + form.Set("model", `{"name":"golang"}`) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Multi.AddSubreddit(ctx, "user/testuser/m/testmulti", "golang") + require.NoError(t, err) +} + +func TestMultiService_DeleteSubreddit(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/multi/user/testuser/m/testmulti/r/golang", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + }) + + _, err := client.Multi.DeleteSubreddit(ctx, "user/testuser/m/testmulti", "golang") + require.NoError(t, err) +} diff --git a/v2/reddit/post-and-comment.go b/v2/reddit/post-and-comment.go new file mode 100644 index 0000000..209604b --- /dev/null +++ b/v2/reddit/post-and-comment.go @@ -0,0 +1,182 @@ +package reddit + +import ( + "context" + "net/http" + "net/url" + "strconv" +) + +// postAndCommentService handles communication with the post and comment +// related methods of the Reddit API. +// This service holds functionality common to both posts and comments. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_links_and_comments +type postAndCommentService struct { + client *Client +} + +type vote int + +// Reddit interprets -1, 0, 1 as downvote, no vote, and upvote, respectively. +const ( + downvote vote = iota - 1 + novote + upvote +) + +// Delete a post or comment via its full ID. +func (s *postAndCommentService) Delete(ctx context.Context, id string) (*Response, error) { + path := "api/del" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Save a post or comment. +func (s *postAndCommentService) Save(ctx context.Context, id string) (*Response, error) { + path := "api/save" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Unsave a post or comment. +func (s *postAndCommentService) Unsave(ctx context.Context, id string) (*Response, error) { + path := "api/unsave" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// EnableReplies enables inbox replies for one of your posts or comments. +func (s *postAndCommentService) EnableReplies(ctx context.Context, id string) (*Response, error) { + path := "api/sendreplies" + + form := url.Values{} + form.Set("id", id) + form.Set("state", "true") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// DisableReplies dsables inbox replies for one of your posts or comments. +func (s *postAndCommentService) DisableReplies(ctx context.Context, id string) (*Response, error) { + path := "api/sendreplies" + + form := url.Values{} + form.Set("id", id) + form.Set("state", "false") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Lock a post or comment, preventing it from receiving new comments. +func (s *postAndCommentService) Lock(ctx context.Context, id string) (*Response, error) { + path := "api/lock" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Unlock a post or comment, allowing it to receive new comments. +func (s *postAndCommentService) Unlock(ctx context.Context, id string) (*Response, error) { + path := "api/unlock" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +func (s *postAndCommentService) vote(ctx context.Context, id string, vote vote) (*Response, error) { + path := "api/vote" + + form := url.Values{} + form.Set("id", id) + form.Set("dir", strconv.Itoa(int(vote))) + form.Set("rank", "10") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Upvote a post or a comment. +func (s *postAndCommentService) Upvote(ctx context.Context, id string) (*Response, error) { + return s.vote(ctx, id, upvote) +} + +// Downvote a post or a comment. +func (s *postAndCommentService) Downvote(ctx context.Context, id string) (*Response, error) { + return s.vote(ctx, id, downvote) +} + +// RemoveVote removes your vote on a post or a comment. +func (s *postAndCommentService) RemoveVote(ctx context.Context, id string) (*Response, error) { + return s.vote(ctx, id, novote) +} + +// Report a post or comment. +// The reason must not be longer than 100 characters. +func (s *postAndCommentService) Report(ctx context.Context, id string, reason string) (*Response, error) { + path := "api/report" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("thing_id", id) + form.Set("reason", reason) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/post.go b/v2/reddit/post.go new file mode 100644 index 0000000..26e485a --- /dev/null +++ b/v2/reddit/post.go @@ -0,0 +1,582 @@ +package reddit + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/google/go-querystring/query" +) + +// PostService handles communication with the post +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_links_and_comments +type PostService struct { + *postAndCommentService + client *Client +} + +type rootSubmittedPost struct { + JSON struct { + Data *Submitted `json:"data,omitempty"` + } `json:"json"` +} + +// Submitted is a newly submitted post on Reddit. +type Submitted struct { + ID string `json:"id,omitempty"` + FullID string `json:"name,omitempty"` + URL string `json:"url,omitempty"` +} + +// SubmitTextRequest are options used for text posts. +type SubmitTextRequest struct { + Subreddit string `url:"sr,omitempty"` + Title string `url:"title,omitempty"` + Text string `url:"text,omitempty"` + + FlairID string `url:"flair_id,omitempty"` + FlairText string `url:"flair_text,omitempty"` + + SendReplies *bool `url:"sendreplies,omitempty"` + NSFW bool `url:"nsfw,omitempty"` + Spoiler bool `url:"spoiler,omitempty"` +} + +// SubmitLinkRequest are options used for link posts. +type SubmitLinkRequest struct { + Subreddit string `url:"sr,omitempty"` + Title string `url:"title,omitempty"` + URL string `url:"url,omitempty"` + + FlairID string `url:"flair_id,omitempty"` + FlairText string `url:"flair_text,omitempty"` + + SendReplies *bool `url:"sendreplies,omitempty"` + Resubmit bool `url:"resubmit,omitempty"` + NSFW bool `url:"nsfw,omitempty"` + Spoiler bool `url:"spoiler,omitempty"` +} + +// Get a post with its comments. +// id is the ID36 of the post, not its full id. +// Example: instead of t3_abc123, use abc123. +func (s *PostService) Get(ctx context.Context, id, subreddit string) (*PostAndComments, *Response, error) { + path := fmt.Sprintf("r/%s/comments/%s/", subreddit, id) + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(PostAndComments) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Duplicates returns the post with the id, and a list of its duplicates. +// id is the ID36 of the post, not its full id. +// Example: instead of t3_abc123, use abc123. +func (s *PostService) Duplicates(ctx context.Context, id string, opts *ListDuplicatePostOptions) (*Post, []*Post, *Response, error) { + path := fmt.Sprintf("duplicates/%s", id) + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, nil, err + } + + var root [2]thing + resp, err := s.client.Do(ctx, req, &root) + if err != nil { + return nil, nil, resp, err + } + + listing1, _ := root[0].Listing() + listing2, _ := root[1].Listing() + + post := listing1.Posts()[0] + duplicates := listing2.Posts() + + resp.After = listing2.After() + return post, duplicates, resp, nil +} + +func (s *PostService) submit(ctx context.Context, v interface{}) (*Submitted, *Response, error) { + path := "api/submit" + + form, err := query.Values(v) + if err != nil { + return nil, nil, err + } + form.Set("api_type", "json") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(rootSubmittedPost) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.JSON.Data, resp, nil +} + +// SubmitText submits a text post. +func (s *PostService) SubmitText(ctx context.Context, opts SubmitTextRequest) (*Submitted, *Response, error) { + form := struct { + SubmitTextRequest + Kind string `url:"kind,omitempty"` + }{opts, "self"} + return s.submit(ctx, form) +} + +// SubmitLink submits a link post. +func (s *PostService) SubmitLink(ctx context.Context, opts SubmitLinkRequest) (*Submitted, *Response, error) { + form := struct { + SubmitLinkRequest + Kind string `url:"kind,omitempty"` + }{opts, "link"} + return s.submit(ctx, form) +} + +// Edit a post. +func (s *PostService) Edit(ctx context.Context, id string, text string) (*Post, *Response, error) { + path := "api/editusertext" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("thing_id", id) + form.Set("text", text) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(Post) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Hide posts. +func (s *PostService) Hide(ctx context.Context, ids ...string) (*Response, error) { + if len(ids) == 0 { + return nil, errors.New("must provide at least 1 id") + } + + path := "api/hide" + + form := url.Values{} + form.Set("id", strings.Join(ids, ",")) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Unhide posts. +func (s *PostService) Unhide(ctx context.Context, ids ...string) (*Response, error) { + if len(ids) == 0 { + return nil, errors.New("must provide at least 1 id") + } + + path := "api/unhide" + + form := url.Values{} + form.Set("id", strings.Join(ids, ",")) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// MarkNSFW marks a post as NSFW. +func (s *PostService) MarkNSFW(ctx context.Context, id string) (*Response, error) { + path := "api/marknsfw" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UnmarkNSFW unmarks a post as NSFW. +func (s *PostService) UnmarkNSFW(ctx context.Context, id string) (*Response, error) { + path := "api/unmarknsfw" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Spoiler marks a post as a spoiler. +func (s *PostService) Spoiler(ctx context.Context, id string) (*Response, error) { + path := "api/spoiler" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Unspoiler unmarks a post as a spoiler. +func (s *PostService) Unspoiler(ctx context.Context, id string) (*Response, error) { + path := "api/unspoiler" + + form := url.Values{} + form.Set("id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Sticky a post in its subreddit. +// When bottom is true, the post will be set as the bottom sticky (the 2nd one). +// If no top sticky exists, the post will become the top sticky regardless. +// When attempting to sticky a post that's already stickied, it will return a 409 Conflict error. +func (s *PostService) Sticky(ctx context.Context, id string, bottom bool) (*Response, error) { + path := "api/set_subreddit_sticky" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", id) + form.Set("state", "true") + if !bottom { + form.Set("num", "1") + } + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Unsticky unstickies a post in its subreddit. +func (s *PostService) Unsticky(ctx context.Context, id string) (*Response, error) { + path := "api/set_subreddit_sticky" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", id) + form.Set("state", "false") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// PinToProfile pins one of your posts to your profile. +// TODO: very inconsistent behaviour, not sure I'm ready to include this parameter yet. +// The pos parameter should be a number between 1-4 (inclusive), indicating the position at which +// the post should appear on your profile. +// Note: The position will be bumped upward if there's space. E.g. if you only have 1 pinned post, +// and you try to pin another post to position 3, it will be pinned at 2. +// When attempting to pin a post that's already pinned, it will return a 409 Conflict error. +func (s *PostService) PinToProfile(ctx context.Context, id string) (*Response, error) { + path := "api/set_subreddit_sticky" + + // if pos < 1 { + // pos = 1 + // } + // if pos > 4 { + // pos = 4 + // } + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", id) + form.Set("state", "true") + form.Set("to_profile", "true") + // form.Set("num", strconv.Itoa(pos)) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UnpinFromProfile unpins one of your posts from your profile. +func (s *PostService) UnpinFromProfile(ctx context.Context, id string) (*Response, error) { + path := "api/set_subreddit_sticky" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", id) + form.Set("state", "false") + form.Set("to_profile", "true") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// setSuggestedSort sets the suggested comment sort for the post. +// sort must be one of: confidence (i.e. best), top, new, controversial, old, random, qa, live +func (s *PostService) setSuggestedSort(ctx context.Context, id string, sort string) (*Response, error) { + path := "api/set_suggested_sort" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", id) + form.Set("sort", sort) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// SetSuggestedSortBest sets the suggested comment sort for the post to best. +func (s *PostService) SetSuggestedSortBest(ctx context.Context, id string) (*Response, error) { + return s.setSuggestedSort(ctx, id, "confidence") +} + +// SetSuggestedSortTop sets the suggested comment sort for the post to top. +func (s *PostService) SetSuggestedSortTop(ctx context.Context, id string) (*Response, error) { + return s.setSuggestedSort(ctx, id, "top") +} + +// SetSuggestedSortNew sets the suggested comment sort for the post to new. +func (s *PostService) SetSuggestedSortNew(ctx context.Context, id string) (*Response, error) { + return s.setSuggestedSort(ctx, id, "new") +} + +// SetSuggestedSortControversial sets the suggested comment sort for the post to controversial. +func (s *PostService) SetSuggestedSortControversial(ctx context.Context, id string) (*Response, error) { + return s.setSuggestedSort(ctx, id, "controversial") +} + +// SetSuggestedSortOld sorts the comments on the posts randomly. +func (s *PostService) SetSuggestedSortOld(ctx context.Context, id string) (*Response, error) { + return s.setSuggestedSort(ctx, id, "old") +} + +// SetSuggestedSortRandom sets the suggested comment sort for the post to random. +func (s *PostService) SetSuggestedSortRandom(ctx context.Context, id string) (*Response, error) { + return s.setSuggestedSort(ctx, id, "random") +} + +// SetSuggestedSortAMA sets the suggested comment sort for the post to a Q&A styled fashion. +func (s *PostService) SetSuggestedSortAMA(ctx context.Context, id string) (*Response, error) { + return s.setSuggestedSort(ctx, id, "qa") +} + +// SetSuggestedSortLive sets the suggested comment sort for the post to stream new comments as they're posted. +// As of now, this is still in beta, so it's not a fully developed feature yet. It just sets the sort as "new" for now. +func (s *PostService) SetSuggestedSortLive(ctx context.Context, id string) (*Response, error) { + return s.setSuggestedSort(ctx, id, "live") +} + +// ClearSuggestedSort clears the suggested comment sort for the post. +func (s *PostService) ClearSuggestedSort(ctx context.Context, id string) (*Response, error) { + return s.setSuggestedSort(ctx, id, "") +} + +// EnableContestMode enables contest mode for the post. +// Comments will be sorted randomly and regular users cannot see comment scores. +func (s *PostService) EnableContestMode(ctx context.Context, id string) (*Response, error) { + path := "api/set_contest_mode" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", id) + form.Set("state", "true") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// DisableContestMode disables contest mode for the post. +func (s *PostService) DisableContestMode(ctx context.Context, id string) (*Response, error) { + path := "api/set_contest_mode" + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", id) + form.Set("state", "false") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// LoadMoreComments retrieves more comments that were left out when initially fetching the post. +func (s *PostService) LoadMoreComments(ctx context.Context, pc *PostAndComments) (*Response, error) { + if pc == nil { + return nil, errors.New("*PostAndComments: cannot be nil") + } + + if !pc.HasMore() { + return nil, nil + } + + postID := pc.Post.FullID + commentIDs := pc.More.Children + + form := url.Values{} + form.Set("api_type", "json") + form.Set("link_id", postID) + form.Set("children", strings.Join(commentIDs, ",")) + + path := "api/morechildren" + + // This was originally a GET, but with POST you can send a bigger payload + // since it's in the body and not the URI. + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + root := new(struct { + JSON struct { + Data struct { + Things things `json:"things"` + } `json:"data"` + } `json:"json"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return resp, err + } + + comments := root.JSON.Data.Things.Comments + for _, c := range comments { + pc.addCommentToTree(c) + } + + noMore := true + + mores := root.JSON.Data.Things.Mores + for _, m := range mores { + if strings.HasPrefix(m.ParentID, kindPost+"_") { + noMore = false + } + pc.addMoreToTree(m) + } + + if noMore { + pc.More = nil + } + + return resp, nil +} + +func (s *PostService) random(ctx context.Context, subreddits ...string) (*PostAndComments, *Response, error) { + path := "random" + if len(subreddits) > 0 { + path = fmt.Sprintf("r/%s/random", strings.Join(subreddits, "+")) + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(PostAndComments) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// RandomFromSubreddits returns a random post and its comments from the subreddits. +// If no subreddits are provided, Reddit runs the query against your subscriptions. +func (s *PostService) RandomFromSubreddits(ctx context.Context, subreddits ...string) (*PostAndComments, *Response, error) { + return s.random(ctx, subreddits...) +} + +// Random returns a random post and its comments from all of Reddit. +func (s *PostService) Random(ctx context.Context) (*PostAndComments, *Response, error) { + return s.random(ctx, "all") +} + +// RandomFromSubscriptions returns a random post and its comments from your subscriptions. +func (s *PostService) RandomFromSubscriptions(ctx context.Context) (*PostAndComments, *Response, error) { + return s.random(ctx) +} + +// MarkVisited marks the post(s) as visited. +// This method requires a subscription to Reddit premium. +func (s *PostService) MarkVisited(ctx context.Context, ids ...string) (*Response, error) { + if len(ids) == 0 { + return nil, errors.New("must provide at least 1 id") + } + + path := "api/store_visits" + + form := url.Values{} + form.Set("links", strings.Join(ids, ",")) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/post_test.go b/v2/reddit/post_test.go new file mode 100644 index 0000000..78d26b3 --- /dev/null +++ b/v2/reddit/post_test.go @@ -0,0 +1,1135 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedPostAndComments = &PostAndComments{ + Post: &Post{ + ID: "testpost", + FullID: "t3_testpost", + Created: &Timestamp{time.Date(2020, 7, 18, 10, 26, 7, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/test/comments/testpost/test/", + URL: "https://www.reddit.com/r/test/comments/testpost/test/", + + Title: "Test", + Body: "Hello", + + Score: 1, + UpvoteRatio: 1, + NumberOfComments: 2, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8077, + + Author: "testuser", + AuthorID: "t2_testuser", + + IsSelfPost: true, + }, + Comments: []*Comment{ + { + ID: "testc1", + FullID: "t1_testc1", + Created: &Timestamp{time.Date(2020, 7, 18, 10, 31, 59, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + ParentID: "t3_testpost", + Permalink: "/r/test/comments/testpost/test/testc1/", + + Body: "Hi", + Author: "testuser", + AuthorID: "t2_testuser", + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + + Score: 1, + Controversiality: 0, + + PostID: "t3_testpost", + + IsSubmitter: true, + CanGild: true, + + Replies: Replies{ + Comments: []*Comment{ + { + ID: "testc2", + FullID: "t1_testc2", + Created: &Timestamp{time.Date(2020, 7, 18, 10, 32, 28, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + ParentID: "t1_testc1", + Permalink: "/r/test/comments/testpost/test/testc2/", + + Body: "Hello", + Author: "testuser", + AuthorID: "t2_testuser", + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + + Score: 1, + Controversiality: 0, + + PostID: "t3_testpost", + + IsSubmitter: true, + CanGild: true, + }, + }, + }, + }, + }, +} + +var expectedSubmittedPost = &Submitted{ + ID: "hw6l6a", + FullID: "t3_hw6l6a", + URL: "https://www.reddit.com/r/test/comments/hw6l6a/test_title/", +} + +var expectedEditedPost = &Post{ + ID: "hw6l6a", + FullID: "t3_hw6l6a", + Created: &Timestamp{time.Date(2020, 7, 23, 1, 24, 55, 0, time.UTC)}, + Edited: &Timestamp{time.Date(2020, 7, 23, 1, 42, 44, 0, time.UTC)}, + + Permalink: "/r/test/comments/hw6l6a/test_title/", + URL: "https://www.reddit.com/r/test/comments/hw6l6a/test_title/", + + Title: "Test Title", + Body: "this is edited", + + Likes: Bool(true), + + Score: 1, + UpvoteRatio: 1, + NumberOfComments: 0, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8128, + + Author: "v_95", + AuthorID: "t2_164ab8", + + Spoiler: true, + IsSelfPost: true, +} + +var expectedPost2 = &Post{ + ID: "i2gvs1", + FullID: "t3_i2gvs1", + Created: &Timestamp{time.Date(2020, 8, 2, 18, 23, 37, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/test/comments/i2gvs1/this_is_a_title/", + URL: "http://example.com", + + Title: "This is a title", + + Likes: Bool(true), + + Score: 1, + UpvoteRatio: 1, + NumberOfComments: 0, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8278, + + Author: "v_95", + AuthorID: "t2_164ab8", +} + +var expectedPostDuplicates = []*Post{ + { + ID: "8kbs85", + FullID: "t3_8kbs85", + Created: &Timestamp{time.Date(2018, 5, 18, 9, 10, 18, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/test/comments/8kbs85/test/", + URL: "http://example.com", + + Title: "test", + + Likes: nil, + + Score: 1, + UpvoteRatio: 0.66, + NumberOfComments: 1, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8278, + + Author: "GarlicoinAccount", + AuthorID: "t2_d2v1r90", + }, + { + ID: "le1tc", + FullID: "t3_le1tc", + Created: &Timestamp{time.Date(2011, 10, 16, 13, 26, 40, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/test/comments/le1tc/test_to_see_if_this_fixes_the_problem_of_my_likes/", + URL: "http://www.example.com", + + Title: "Test to see if this fixes the problem of my \"likes\" from the last 7 months vanishing.", + + Likes: nil, + + Score: 2, + UpvoteRatio: 1, + NumberOfComments: 1, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8278, + + Author: "prog101", + AuthorID: "t2_8dyo", + }, +} + +func TestPostService_Get(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/post.json") + require.NoError(t, err) + + mux.HandleFunc("/comments/abc123", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + postAndComments, _, err := client.Post.Get(ctx, "abc123") + require.NoError(t, err) + require.Equal(t, expectedPostAndComments, postAndComments) +} + +func TestPostService_Duplicates(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/duplicates.json") + require.NoError(t, err) + + mux.HandleFunc("/duplicates/abc123", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "2") + form.Set("sr", "test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + post, postDuplicates, resp, err := client.Post.Duplicates(ctx, "abc123", &ListDuplicatePostOptions{ + ListOptions: ListOptions{ + Limit: 2, + }, + Subreddit: "test", + }) + require.NoError(t, err) + require.Equal(t, expectedPost2, post) + require.Equal(t, expectedPostDuplicates, postDuplicates) + require.Equal(t, "t3_le1tc", resp.After) +} + +func TestPostService_SubmitText(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/submit.json") + require.NoError(t, err) + + mux.HandleFunc("/api/submit", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("kind", "self") + form.Set("sr", "test") + form.Set("title", "Test Title") + form.Set("text", "Test Text") + form.Set("spoiler", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + submittedPost, _, err := client.Post.SubmitText(ctx, SubmitTextRequest{ + Subreddit: "test", + Title: "Test Title", + Text: "Test Text", + Spoiler: true, + }) + require.NoError(t, err) + require.Equal(t, expectedSubmittedPost, submittedPost) +} + +func TestPostService_SubmitLink(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/submit.json") + require.NoError(t, err) + + mux.HandleFunc("/api/submit", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("kind", "link") + form.Set("sr", "test") + form.Set("title", "Test Title") + form.Set("url", "https://www.example.com") + form.Set("sendreplies", "false") + form.Set("resubmit", "true") + form.Set("nsfw", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + submittedPost, _, err := client.Post.SubmitLink(ctx, SubmitLinkRequest{ + Subreddit: "test", + Title: "Test Title", + URL: "https://www.example.com", + SendReplies: Bool(false), + Resubmit: true, + NSFW: true, + }) + require.NoError(t, err) + require.Equal(t, expectedSubmittedPost, submittedPost) +} + +func TestPostService_Edit(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/edit.json") + require.NoError(t, err) + + mux.HandleFunc("/api/editusertext", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("return_rtjson", "true") + form.Set("thing_id", "t3_test") + form.Set("text", "test edit") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + editedPost, _, err := client.Post.Edit(ctx, "t3_test", "test edit") + require.NoError(t, err) + require.Equal(t, expectedEditedPost, editedPost) +} + +func TestPostService_Hide(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/hide", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "1,2,3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Post.Hide(ctx) + require.EqualError(t, err, "must provide at least 1 id") + + resp, err := client.Post.Hide(ctx, "1", "2", "3") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Unhide(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/unhide", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "1,2,3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Post.Unhide(ctx) + require.EqualError(t, err, "must provide at least 1 id") + + resp, err := client.Post.Unhide(ctx, "1", "2", "3") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_MarkNSFW(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/marknsfw", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.MarkNSFW(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_UnmarkNSFW(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/unmarknsfw", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.UnmarkNSFW(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Spoiler(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/spoiler", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Spoiler(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Unspoiler(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/unspoiler", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Unspoiler(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Sticky(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_subreddit_sticky", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("state", "true") + form.Set("num", "1") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Sticky(ctx, "t3_test", false) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Unsticky(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_subreddit_sticky", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("state", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Unsticky(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_PinToProfile(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_subreddit_sticky", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("state", "true") + form.Set("to_profile", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.PinToProfile(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_UnpinFromProfile(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_subreddit_sticky", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("state", "false") + form.Set("to_profile", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.UnpinFromProfile(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_SetSuggestedSortBest(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_suggested_sort", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("sort", "confidence") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.SetSuggestedSortBest(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_SetSuggestedSortTop(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_suggested_sort", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("sort", "top") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.SetSuggestedSortTop(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_SetSuggestedSortNew(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_suggested_sort", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("sort", "new") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.SetSuggestedSortNew(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_SetSuggestedSortControversial(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_suggested_sort", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("sort", "controversial") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.SetSuggestedSortControversial(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_SetSuggestedSortOld(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_suggested_sort", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("sort", "old") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.SetSuggestedSortOld(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_SetSuggestedSortRandom(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_suggested_sort", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("sort", "random") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.SetSuggestedSortRandom(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_SetSuggestedSortAMA(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_suggested_sort", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("sort", "qa") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.SetSuggestedSortAMA(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_SetSuggestedSortLive(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_suggested_sort", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("sort", "live") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.SetSuggestedSortLive(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_ClearSuggestedSort(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_suggested_sort", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("sort", "") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.ClearSuggestedSort(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_EnableContestMode(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_contest_mode", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("state", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.EnableContestMode(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_DisableContestMode(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/set_contest_mode", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("id", "t3_test") + form.Set("state", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.DisableContestMode(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_LoadMoreReplies(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/more.json") + require.NoError(t, err) + + mux.HandleFunc("/api/morechildren", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("link_id", "t3_123") + form.Set("children", "def,ghi,jkl") + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, err = client.Post.LoadMoreComments(ctx, nil) + require.EqualError(t, err, "*PostAndComments: cannot be nil") + + resp, err := client.Post.LoadMoreComments(ctx, &PostAndComments{}) + require.Nil(t, resp) + require.Nil(t, err) + + pc := &PostAndComments{ + Post: &Post{ + FullID: "t3_123", + }, + Comments: []*Comment{ + { + FullID: "t1_abc", + }, + }, + More: &More{ + Children: []string{"def", "ghi", "jkl"}, + }, + } + + _, err = client.Post.LoadMoreComments(ctx, pc) + require.NoError(t, err) + require.False(t, pc.HasMore()) + require.Len(t, pc.Comments, 2) + require.True(t, pc.Comments[1].HasMore()) + require.Len(t, pc.Comments[0].Replies.Comments, 1) + require.Len(t, pc.Comments[0].Replies.Comments[0].Replies.Comments, 1) +} + +func TestPostService_RandomFromSubreddits(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/post.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/random", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + postAndComments, _, err := client.Post.RandomFromSubreddits(ctx, "test") + require.NoError(t, err) + require.Equal(t, expectedPostAndComments, postAndComments) +} + +func TestPostService_Random(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/post.json") + require.NoError(t, err) + + mux.HandleFunc("/r/all/random", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + postAndComments, _, err := client.Post.Random(ctx) + require.NoError(t, err) + require.Equal(t, expectedPostAndComments, postAndComments) +} + +func TestPostService_RandomFromSubscriptions(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/post.json") + require.NoError(t, err) + + mux.HandleFunc("/random", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + postAndComments, _, err := client.Post.RandomFromSubscriptions(ctx) + require.NoError(t, err) + require.Equal(t, expectedPostAndComments, postAndComments) +} + +func TestPostService_Delete(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/del", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Delete(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Save(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/save", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Save(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Unsave(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/unsave", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Unsave(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_EnableReplies(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/sendreplies", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("state", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.EnableReplies(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_DisableReplies(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/sendreplies", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("state", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.DisableReplies(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Lock(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/lock", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Lock(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Unlock(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/unlock", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Unlock(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Upvote(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("dir", "1") + form.Set("rank", "10") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Upvote(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_Downvote(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("dir", "-1") + form.Set("rank", "10") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.Downvote(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_RemoveVote(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "t3_test") + form.Set("dir", "0") + form.Set("rank", "10") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + resp, err := client.Post.RemoveVote(ctx, "t3_test") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPostService_MarkVisited(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/store_visits", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("links", "t3_test1,t3_test2,t3_test3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Post.MarkVisited(ctx) + require.EqualError(t, err, "must provide at least 1 id") + + _, err = client.Post.MarkVisited(ctx, "t3_test1", "t3_test2", "t3_test3") + require.NoError(t, err) +} + +func TestPostService_Report(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/report", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("thing_id", "t3_test") + form.Set("reason", "test reason") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Post.Report(ctx, "t3_test", "test reason") + require.NoError(t, err) +} diff --git a/v2/reddit/reddit-oauth.go b/v2/reddit/reddit-oauth.go new file mode 100644 index 0000000..13030d6 --- /dev/null +++ b/v2/reddit/reddit-oauth.go @@ -0,0 +1,77 @@ +/* +Docs: +- https://www.reddit.com/dev/api/ +- https://github.com/reddit-archive/reddit/wiki/api +- https://github.com/reddit-archive/reddit/wiki/OAuth2 +- https://github.com/reddit-archive/reddit/wiki/OAuth2-Quick-Start-Example + +1. Go to https://www.reddit.com/prefs/apps and create an app. There are 3 types of apps: + - Web app. Service is available over http or https, preferably the latter. + - Installed app, such as a mobile app on a user's device which you can't control. + Redirect the user to a URI after they grant your app permissions. + - Script (the simplest type of app). Select this if you are the only person who will + use the app. Only has access to your account. + +Best option for a client like this is to use the script option. + +2. After creating the app, you will get a client id and client secret. + +3. Send a POST request (with the Content-Type header set to "application/x-www-form-urlencoded") +to https://www.reddit.com/api/v1/access_token with the following form values: + - grant_type=password + - username={your Reddit username} + - password={your Reddit password} + +4. You should receive a response body like the following: +{ + "access_token": "70743860-DRhHVNSEOMu1ldlI", + "token_type": "bearer", + "expires_in": 3600, + "scope": "*" +} +*/ + +package reddit + +import ( + "context" + "net/http" + + "golang.org/x/oauth2" +) + +type oauthTokenSource struct { + ctx context.Context + config *oauth2.Config + username, password string +} + +func (s *oauthTokenSource) Token() (*oauth2.Token, error) { + return s.config.PasswordCredentialsToken(s.ctx, s.username, s.password) +} + +func oauthTransport(client *Client) http.RoundTripper { + httpClient := &http.Client{Transport: client.client.Transport} + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + + config := &oauth2.Config{ + ClientID: client.ID, + ClientSecret: client.Secret, + Endpoint: oauth2.Endpoint{ + TokenURL: client.TokenURL.String(), + AuthStyle: oauth2.AuthStyleInHeader, + }, + } + + tokenSource := oauth2.ReuseTokenSource(nil, &oauthTokenSource{ + ctx: ctx, + config: config, + username: client.Username, + password: client.Password, + }) + + return &oauth2.Transport{ + Source: tokenSource, + Base: client.client.Transport, + } +} diff --git a/v2/reddit/reddit-options.go b/v2/reddit/reddit-options.go new file mode 100644 index 0000000..25d397b --- /dev/null +++ b/v2/reddit/reddit-options.go @@ -0,0 +1,78 @@ +package reddit + +import ( + "errors" + "net/http" + "net/url" + "os" +) + +// Opt is used to further configure a client upon initialization. +type Opt func(*Client) error + +// WithHTTPClient sets the HTTP client which will be used to make requests. +func WithHTTPClient(httpClient *http.Client) Opt { + return func(c *Client) error { + if httpClient == nil { + return errors.New("*http.Client: cannot be nil") + } + c.client = httpClient + return nil + } +} + +// WithUserAgent sets the User-Agent header for requests made with the client. +// Reddit recommends the following format for the user agent: +// :: (by /u/) +func WithUserAgent(ua string) Opt { + return func(c *Client) error { + c.userAgent = ua + return nil + } +} + +// WithBaseURL sets the base URL for the client to make requests to. +func WithBaseURL(u string) Opt { + return func(c *Client) error { + url, err := url.Parse(u) + if err != nil { + return err + } + c.BaseURL = url + return nil + } +} + +// WithTokenURL sets the url used to get access tokens. +func WithTokenURL(u string) Opt { + return func(c *Client) error { + url, err := url.Parse(u) + if err != nil { + return err + } + c.TokenURL = url + return nil + } +} + +// FromEnv configures the client with values from environment variables. +// Supported environment variables: +// GO_REDDIT_CLIENT_ID to set the client's id. +// GO_REDDIT_CLIENT_SECRET to set the client's secret. +// GO_REDDIT_CLIENT_USERNAME to set the client's username. +// GO_REDDIT_CLIENT_PASSWORD to set the client's password. +func FromEnv(c *Client) error { + if v := os.Getenv("GO_REDDIT_CLIENT_ID"); v != "" { + c.ID = v + } + if v := os.Getenv("GO_REDDIT_CLIENT_SECRET"); v != "" { + c.Secret = v + } + if v := os.Getenv("GO_REDDIT_CLIENT_USERNAME"); v != "" { + c.Username = v + } + if v := os.Getenv("GO_REDDIT_CLIENT_PASSWORD"); v != "" { + c.Password = v + } + return nil +} diff --git a/v2/reddit/reddit-options_test.go b/v2/reddit/reddit-options_test.go new file mode 100644 index 0000000..3db5ce8 --- /dev/null +++ b/v2/reddit/reddit-options_test.go @@ -0,0 +1,74 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWithHTTPClient(t *testing.T) { + _, err := NewClient(Credentials{}, WithHTTPClient(nil)) + require.EqualError(t, err, "*http.Client: cannot be nil") + + _, err = NewClient(Credentials{}, WithHTTPClient(&http.Client{})) + require.NoError(t, err) +} + +func TestWithUserAgent(t *testing.T) { + c, err := NewClient(Credentials{}, WithUserAgent("test")) + require.NoError(t, err) + require.Equal(t, "test", c.UserAgent()) + + c, err = NewClient(Credentials{}, WithUserAgent("")) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("golang:%s:v%s", libraryName, libraryVersion), c.UserAgent()) +} + +func TestWithBaseURL(t *testing.T) { + c, err := NewClient(Credentials{}, WithBaseURL(":")) + urlErr, ok := err.(*url.Error) + require.True(t, ok) + require.Equal(t, "parse", urlErr.Op) + + baseURL := "http://localhost:8080" + c, err = NewClient(Credentials{}, WithBaseURL(baseURL)) + require.NoError(t, err) + require.Equal(t, baseURL, c.BaseURL.String()) +} + +func TestWithTokenURL(t *testing.T) { + c, err := NewClient(Credentials{}, WithTokenURL(":")) + urlErr, ok := err.(*url.Error) + require.True(t, ok) + require.Equal(t, "parse", urlErr.Op) + + tokenURL := "http://localhost:8080/api/v1/access_token" + c, err = NewClient(Credentials{}, WithTokenURL(tokenURL)) + require.NoError(t, err) + require.Equal(t, tokenURL, c.TokenURL.String()) +} + +func TestFromEnv(t *testing.T) { + os.Setenv("GO_REDDIT_CLIENT_ID", "id1") + defer os.Unsetenv("GO_REDDIT_CLIENT_ID") + + os.Setenv("GO_REDDIT_CLIENT_SECRET", "secret1") + defer os.Unsetenv("GO_REDDIT_CLIENT_SECRET") + + os.Setenv("GO_REDDIT_CLIENT_USERNAME", "username1") + defer os.Unsetenv("GO_REDDIT_CLIENT_USERNAME") + + os.Setenv("GO_REDDIT_CLIENT_PASSWORD", "password1") + defer os.Unsetenv("GO_REDDIT_CLIENT_PASSWORD") + + c, err := NewClient(Credentials{}, FromEnv) + require.NoError(t, err) + require.Equal(t, "id1", c.ID) + require.Equal(t, "secret1", c.Secret) + require.Equal(t, "username1", c.Username) + require.Equal(t, "password1", c.Password) +} diff --git a/v2/reddit/reddit-user-agent.go b/v2/reddit/reddit-user-agent.go new file mode 100644 index 0000000..0e69b67 --- /dev/null +++ b/v2/reddit/reddit-user-agent.go @@ -0,0 +1,44 @@ +package reddit + +import "net/http" + +// cloneRequest returns a clone of the provided *http.Request. +// The clone is a shallow copy of the struct and its Header map, +// since we'll only be modify the headers. +// Per the specification of http.RoundTripper, we should not directly modify a request. +func cloneRequest(r *http.Request) *http.Request { + r2 := new(http.Request) + *r2 = *r + // deep copy of the Header + r2.Header = make(http.Header, len(r.Header)) + for k, s := range r.Header { + r2.Header[k] = append([]string(nil), s...) + } + return r2 +} + +// Sets the User-Agent header for requests. +// We need to set a custom user agent because using the one set by the +// stdlib gives us 429 Too Many Requests responses from the Reddit API. +type userAgentTransport struct { + userAgent string + Base http.RoundTripper +} + +func (t *userAgentTransport) setUserAgent(req *http.Request) *http.Request { + req2 := cloneRequest(req) + req2.Header.Set(headerUserAgent, t.userAgent) + return req2 +} + +func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := t.setUserAgent(req) + return t.base().RoundTrip(req2) +} + +func (t *userAgentTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} diff --git a/v2/reddit/reddit.go b/v2/reddit/reddit.go new file mode 100644 index 0000000..6073ef8 --- /dev/null +++ b/v2/reddit/reddit.go @@ -0,0 +1,671 @@ +package reddit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/enetx/surf" + "github.com/google/go-querystring/query" + "golang.org/x/oauth2" +) + +const ( + libraryName = "git.the-red-comet.org/guy176251/go-reddit" + libraryVersion = "2.0.0" + + defaultBaseURL = "https://oauth.reddit.com" + defaultBaseURLReadonly = "https://reddit.com" + defaultTokenURL = "https://www.reddit.com/api/v1/access_token" + + mediaTypeJSON = "application/json" + mediaTypeForm = "application/x-www-form-urlencoded" + + headerContentType = "Content-Type" + headerAccept = "Accept" + headerUserAgent = "User-Agent" + + headerRateLimitRemaining = "X-Ratelimit-Remaining" + headerRateLimitUsed = "X-Ratelimit-Used" + headerRateLimitReset = "X-Ratelimit-Reset" +) + +var defaultClient, _ = NewReadonlyClient() + +// DefaultClient returns a valid, read-only client with limited access to the Reddit API. +func DefaultClient() *Client { + return defaultClient +} + +// RequestCompletionCallback defines the type of the request callback function. +type RequestCompletionCallback func(*http.Request, *http.Response) + +// Credentials are used to authenticate to make requests to the Reddit API. +type Credentials struct { + ID string + Secret string + Username string + Password string +} + +// Client manages communication with the Reddit API. +type Client struct { + // HTTP client used to communicate with the Reddit API. + client *http.Client + + BaseURL *url.URL + TokenURL *url.URL + + userAgent string + + rateMu sync.Mutex + rate Rate + + ID string + Secret string + Username string + Password string + + // This is the client's user ID in Reddit's database. + redditID string + + Account *AccountService + Collection *CollectionService + Comment *CommentService + Emoji *EmojiService + Flair *FlairService + Gold *GoldService + Listings *ListingsService + LiveThread *LiveThreadService + Message *MessageService + Moderation *ModerationService + Multi *MultiService + Post *PostService + Stream *StreamService + Subreddit *SubredditService + User *UserService + Widget *WidgetService + Wiki *WikiService + + oauth2Transport *oauth2.Transport + + onRequestCompleted RequestCompletionCallback +} + +// OnRequestCompleted sets the client's request completion callback. +func (c *Client) OnRequestCompleted(rc RequestCompletionCallback) { + c.onRequestCompleted = rc +} + +func newClient() *Client { + baseURL, _ := url.Parse(defaultBaseURL) + tokenURL, _ := url.Parse(defaultTokenURL) + + surf_client := surf.NewClient(). + Builder(). + Impersonate(). + Windows(). + Firefox(). + Session(). + Build(). + Unwrap(). + Std() + + client := &Client{client: surf_client, BaseURL: baseURL, TokenURL: tokenURL} + + client.Account = &AccountService{client: client} + client.Collection = &CollectionService{client: client} + client.Emoji = &EmojiService{client: client} + client.Flair = &FlairService{client: client} + client.Gold = &GoldService{client: client} + client.Listings = &ListingsService{client: client} + client.LiveThread = &LiveThreadService{client: client} + client.Message = &MessageService{client: client} + client.Moderation = &ModerationService{client: client} + client.Multi = &MultiService{client: client} + client.Stream = &StreamService{client: client} + client.Subreddit = &SubredditService{client: client} + client.User = &UserService{client: client} + client.Widget = &WidgetService{client: client} + client.Wiki = &WikiService{client: client} + + postAndCommentService := &postAndCommentService{client: client} + client.Comment = &CommentService{client: client, postAndCommentService: postAndCommentService} + client.Post = &PostService{client: client, postAndCommentService: postAndCommentService} + + return client +} + +// NewClient returns a new Reddit API client. +// Use an Opt to configure the client credentials, such as WithHTTPClient or WithUserAgent. +// If the FromEnv option is used with the correct environment variables, an empty struct can +// be passed in as the credentials, since they will be overridden. +func NewClient(credentials Credentials, opts ...Opt) (*Client, error) { + client := newClient() + + client.ID = credentials.ID + client.Secret = credentials.Secret + client.Username = credentials.Username + client.Password = credentials.Password + + for _, opt := range opts { + if err := opt(client); err != nil { + return nil, err + } + } + + userAgentTransport := &userAgentTransport{ + userAgent: client.UserAgent(), + Base: client.client.Transport, + } + client.client.Transport = userAgentTransport + + if client.client.CheckRedirect == nil { + client.client.CheckRedirect = client.redirect + } + + oauthTransport := oauthTransport(client) + client.client.Transport = oauthTransport + + return client, nil +} + +// NewReadonlyClient returns a new read-only Reddit API client. +// The client will have limited access to the Reddit API. +// Options that modify credentials (such as FromEnv) won't have any effect on this client. +func NewReadonlyClient(opts ...Opt) (*Client, error) { + client := newClient() + client.BaseURL, _ = url.Parse(defaultBaseURLReadonly) + + for _, opt := range opts { + if err := opt(client); err != nil { + return nil, err + } + } + + if client.client == nil { + client.client = &http.Client{} + } + + userAgentTransport := &userAgentTransport{ + userAgent: client.UserAgent(), + Base: client.client.Transport, + } + client.client.Transport = userAgentTransport + + return client, nil +} + +// todo... +// Some endpoints (notably the ones to get random subreddits/posts) redirect to a +// reddit.com url, which returns a 403 Forbidden for some reason, unless the url's +// host is changed to oauth.reddit.com +func (c *Client) redirect(req *http.Request, via []*http.Request) error { + redirectURL := req.URL.String() + redirectURL = strings.Replace(redirectURL, "https://www.reddit.com", defaultBaseURL, 1) + + reqURL, err := url.Parse(redirectURL) + if err != nil { + return err + } + req.URL = reqURL + + return nil +} + +// The readonly Reddit url needs .json at the end of its path to return responses in JSON instead of HTML. +func (c *Client) appendJSONExtensionToRequestURLPath(req *http.Request) { + readonlyURL, err := url.Parse(defaultBaseURLReadonly) + if err != nil { + return + } + + if req.URL.Host != readonlyURL.Host { + return + } + + req.URL.Path += ".json" +} + +// UserAgent returns the client's user agent. +func (c *Client) UserAgent() string { + if c.userAgent == "" { + userAgent := fmt.Sprintf("golang:%s:v%s", libraryName, libraryVersion) + if c.Username != "" { + userAgent += fmt.Sprintf(" (by /u/%s)", c.Username) + } + c.userAgent = userAgent + } + return c.userAgent +} + +// NewRequest creates an API request with form data as the body. +// The path is the relative URL which will be resolved to the BaseURL of the Client. +// It should always be specified without a preceding slash. +func (c *Client) NewRequest(method string, path string, form url.Values) (*http.Request, error) { + u, err := c.BaseURL.Parse(path) + if err != nil { + return nil, err + } + + var body io.Reader + if form != nil { + body = strings.NewReader(form.Encode()) + } + + req, err := http.NewRequest(method, u.String(), body) + if err != nil { + return nil, err + } + + c.appendJSONExtensionToRequestURLPath(req) + req.Header.Add(headerContentType, mediaTypeForm) + req.Header.Add(headerAccept, mediaTypeJSON) + + return req, nil +} + +// NewJSONRequest creates an API request with a JSON body. +// The path is the relative URL which will be resolved to the BaseURL of the Client. +// It should always be specified without a preceding slash. +func (c *Client) NewJSONRequest(method string, path string, body interface{}) (*http.Request, error) { + u, err := c.BaseURL.Parse(path) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + if body != nil { + err = json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + + reqBody := bytes.NewReader(buf.Bytes()) + req, err := http.NewRequest(method, u.String(), reqBody) + if err != nil { + return nil, err + } + + c.appendJSONExtensionToRequestURLPath(req) + req.Header.Add(headerContentType, mediaTypeJSON) + req.Header.Add(headerAccept, mediaTypeJSON) + + return req, nil +} + +// Response is a Reddit response. This wraps the standard http.Response returned from Reddit. +type Response struct { + *http.Response + + // Pagination anchor indicating there are more results after this id. + After string + + // Rate limit information. + Rate Rate +} + +// newResponse creates a new Response for the provided http.Response. +func newResponse(r *http.Response) *Response { + response := Response{Response: r} + response.Rate = parseRate(r) + return &response +} + +func (r *Response) populateAnchors(a anchor) { + r.After = a.After() +} + +// parseRate parses the rate related headers. +func parseRate(r *http.Response) Rate { + var rate Rate + if remaining := r.Header.Get(headerRateLimitRemaining); remaining != "" { + v, _ := strconv.ParseFloat(remaining, 64) + rate.Remaining = int(v) + } + if used := r.Header.Get(headerRateLimitUsed); used != "" { + rate.Used, _ = strconv.Atoi(used) + } + if reset := r.Header.Get(headerRateLimitReset); reset != "" { + if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 { + rate.Reset = time.Now().Truncate(time.Second).Add(time.Second * time.Duration(v)) + } + } + return rate +} + +// Do sends an API request and returns the API response. The API response is JSON decoded and stored in the value +// pointed to by v, or returned as an error if an API error has occurred. If v implements the io.Writer interface, +// the raw response will be written to v, without attempting to decode it. +func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Response, error) { + slog.Debug("Making API request to reddit", "url", req.URL.String(), "method", req.Method) + if err := c.checkRateLimitBeforeDo(req); err != nil { + return &Response{ + Response: err.Response, + Rate: err.Rate, + }, err + } + + resp, err := DoRequestWithClient(ctx, c.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if c.onRequestCompleted != nil { + c.onRequestCompleted(req, resp) + } + + response := newResponse(resp) + + c.rateMu.Lock() + c.rate = response.Rate + c.rateMu.Unlock() + + err = CheckResponse(resp) + if err != nil || v == nil { + return response, err + } + + body, err := ResponseReader(resp) + if err != nil { + return response, err + } + + w, ok := v.(io.Writer) + if ok { + _, err = io.Copy(w, body) + if err != nil { + return response, err + } + } else { + err = json.NewDecoder(body).Decode(v) + if err != nil { + return response, err + } + } + + if anchor, ok := v.(anchor); ok { + response.populateAnchors(anchor) + } + + return response, nil +} + +func (c *Client) checkRateLimitBeforeDo(req *http.Request) *RateLimitError { + c.rateMu.Lock() + rate := c.rate + c.rateMu.Unlock() + + if !rate.Reset.IsZero() && rate.Remaining == 0 && time.Now().Before(rate.Reset) { + // Create a fake 429 response. + resp := &http.Response{ + Status: http.StatusText(http.StatusTooManyRequests), + StatusCode: http.StatusTooManyRequests, + Request: req, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("")), + } + return &RateLimitError{ + Rate: rate, + Response: resp, + Message: fmt.Sprintf("API rate limit still exceeded until %s, not making remote request.", rate.Reset), + } + } + + return nil +} + +// id returns the client's Reddit ID. +func (c *Client) id(ctx context.Context) (string, *Response, error) { + if c.redditID != "" { + return c.redditID, nil, nil + } + + self, resp, err := c.User.Get(ctx, c.Username) + if err != nil { + return "", resp, err + } + + c.redditID = fmt.Sprintf("%s_%s", kindUser, self.ID) + return c.redditID, resp, nil +} + +// DoRequest submits an HTTP request. +func DoRequest(ctx context.Context, req *http.Request) (*http.Response, error) { + return DoRequestWithClient(ctx, http.DefaultClient, req) +} + +// DoRequestWithClient submits an HTTP request using the specified client. +func DoRequestWithClient(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) { + req = req.WithContext(ctx) + return client.Do(req) +} + +// CheckResponse checks the API response for errors, and returns them if present. +// A response is considered an error if it has a status code outside the 200 range. +// Reddit also sometimes sends errors with 200 codes; we check for those too. +func CheckResponse(r *http.Response) error { + if r.Header.Get(headerRateLimitRemaining) == "0" { + err := &RateLimitError{ + Rate: parseRate(r), + Response: r, + } + err.Message = fmt.Sprintf("API rate limit has been exceeded until %s.", err.Rate.Reset) + return err + } + + jsonErrorResponse := &JSONErrorResponse{Response: r} + + data, err := io.ReadAll(r.Body) + if err == nil && len(data) > 0 { + json.Unmarshal(data, jsonErrorResponse) + if len(jsonErrorResponse.JSON.Errors) > 0 { + return jsonErrorResponse + } + } + + // reset response body + r.Body = io.NopCloser(bytes.NewBuffer(data)) + + if c := r.StatusCode; c >= 200 && c <= 299 { + return nil + } + + errorResponse := &ErrorResponse{Response: r} + data, err = io.ReadAll(r.Body) + if err == nil && len(data) > 0 { + err := json.Unmarshal(data, errorResponse) + if err != nil { + errorResponse.Message = string(data) + } + } + + return errorResponse +} + +// Rate represents the rate limit for the client. +type Rate struct { + // The number of remaining requests the client can make in the current 10-minute window. + Remaining int `json:"remaining"` + // The number of requests the client has made in the current 10-minute window. + Used int `json:"used"` + // The time at which the current rate limit will reset. + Reset time.Time `json:"reset"` +} + +// A lot of Reddit's responses return a "thing": { "kind": "...", "data": {...} } +// So this is just a nice convenient method to have. +func (c *Client) getThing(ctx context.Context, path string, opts interface{}) (*thing, *Response, error) { + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := c.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + t := new(thing) + resp, err := c.Do(ctx, req, t) + if err != nil { + return nil, resp, err + } + + return t, resp, nil +} + +func (c *Client) getListing(ctx context.Context, path string, opts interface{}) (*listing, *Response, error) { + t, resp, err := c.getThing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + l, _ := t.Listing() + return l, resp, nil +} + +// ListOptions specifies the optional parameters to various API calls that return a listing. +type ListOptions struct { + // Maximum number of items to be returned. + // Generally, the default is 25 and max is 100. + Limit int `url:"limit,omitempty"` + + // The full ID of an item in the listing to use + // as the anchor point of the list. Only items + // appearing after it will be returned. + After string `url:"after,omitempty"` + + // The full ID of an item in the listing to use + // as the anchor point of the list. Only items + // appearing before it will be returned. + Before string `url:"before,omitempty"` +} + +// ListSubredditOptions defines possible options used when searching for subreddits. +type ListSubredditOptions struct { + ListOptions + // One of: relevance, activity. + Sort string `url:"sort,omitempty"` + + IncludeNsfw bool `url:"include_over_18,omitempty"` +} + +// ListPostOptions defines possible options used when getting posts from a subreddit. +type ListPostOptions struct { + ListOptions + // One of: hour, day, week, month, year, all. + Time string `url:"t,omitempty"` +} + +// ListPostSearchOptions defines possible options used when searching for posts within a subreddit. +type ListPostSearchOptions struct { + ListPostOptions + // One of: relevance, hot, top, new, comments. + Sort string `url:"sort,omitempty"` + + IncludeNsfw bool `url:"include_over_18,omitempty"` +} + +// ListUserOverviewOptions defines possible options used when getting a user's post and/or comments. +type ListUserOverviewOptions struct { + ListOptions + // One of: hot, new, top, controversial. + Sort string `url:"sort,omitempty"` + // One of: hour, day, week, month, year, all. + Time string `url:"t,omitempty"` +} + +// ListDuplicatePostOptions defines possible options used when getting duplicates of a post, i.e. +// other submissions of the same URL. +type ListDuplicatePostOptions struct { + ListOptions + // If empty, it'll search for duplicates in all subreddits. + Subreddit string `url:"sr,omitempty"` + // One of: num_comments, new. + Sort string `url:"sort,omitempty"` + // If true, the search will only return duplicates that are + // crossposts of the original post. + CrosspostsOnly bool `url:"crossposts_only,omitempty"` +} + +// ListModActionOptions defines possible options used when getting moderation actions in a subreddit. +type ListModActionOptions struct { + // The max for the limit parameter here is 500. + ListOptions + // If empty, the search will return all action types. + // One of: banuser, unbanuser, spamlink, removelink, approvelink, spamcomment, removecomment, + // approvecomment, addmoderator, showcomment, invitemoderator, uninvitemoderator, acceptmoderatorinvite, + // removemoderator, addcontributor, removecontributor, editsettings, editflair, distinguish, marknsfw, + // wikibanned, wikicontributor, wikiunbanned, wikipagelisted, removewikicontributor, wikirevise, + // wikipermlevel, ignorereports, unignorereports, setpermissions, setsuggestedsort, sticky, unsticky, + // setcontestmode, unsetcontestmode, lock, unlock, muteuser, unmuteuser, createrule, editrule, + // reorderrules, deleterule, spoiler, unspoiler, modmail_enrollment, community_styling, community_widgets, + // markoriginalcontent, collections, events, hidden_award, add_community_topics, remove_community_topics, + // create_scheduled_post, edit_scheduled_post, delete_scheduled_post, submit_scheduled_post, + // edit_post_requirements, invitesubscriber, submit_content_rating_survey. + Type string `url:"type,omitempty"` + // If provided, only return the actions of this moderator. + Moderator string `url:"mod,omitempty"` +} + +func addOptions(s string, opt interface{}) (string, error) { + v := reflect.ValueOf(opt) + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil + } + + origURL, err := url.Parse(s) + if err != nil { + return s, err + } + + origValues := origURL.Query() + + newValues, err := query.Values(opt) + if err != nil { + return s, err + } + + for k, v := range newValues { + origValues[k] = v + } + + origURL.RawQuery = origValues.Encode() + return origURL.String(), nil +} + +// String is a helper routine that allocates a new string value +// to store v and returns a pointer to it. +func String(v string) *string { + p := new(string) + *p = v + return p +} + +// Int is a helper routine that allocates a new int value +// to store v and returns a pointer to it. +func Int(v int) *int { + p := new(int) + *p = v + return p +} + +// Bool is a helper routine that allocates a new bool value +// to store v and returns a pointer to it. +func Bool(v bool) *bool { + p := new(bool) + *p = v + return p +} diff --git a/v2/reddit/reddit_test.go b/v2/reddit/reddit_test.go new file mode 100644 index 0000000..4853cab --- /dev/null +++ b/v2/reddit/reddit_test.go @@ -0,0 +1,295 @@ +package reddit + +import ( + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func setup(t testing.TB) (*Client, *http.ServeMux) { + mux := http.NewServeMux() + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc("/api/v1/access_token", func(w http.ResponseWriter, r *http.Request) { + response := `{ + "access_token": "token1", + "token_type": "bearer", + "expires_in": 3600, + "scope": "*" + }` + w.Header().Add(headerContentType, mediaTypeJSON) + fmt.Fprint(w, response) + }) + + client, _ := NewClient( + Credentials{"id1", "secret1", "user1", "password1"}, + WithBaseURL(server.URL), + WithTokenURL(server.URL+"/api/v1/access_token"), + ) + + return client, mux +} + +func readFileContents(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + bytes, err := ioutil.ReadAll(file) + if err != nil { + return "", err + } + + return string(bytes), err +} + +func testClientServices(t *testing.T, c *Client) { + services := []string{ + "Account", + "Collection", + "Comment", + "Emoji", + "Flair", + "Gold", + "Listings", + "LiveThread", + "Message", + "Moderation", + "Multi", + "Post", + "Stream", + "Subreddit", + "User", + "Widget", + "Wiki", + } + + cp := reflect.ValueOf(c) + cv := reflect.Indirect(cp) + + for _, s := range services { + require.Falsef(t, cv.FieldByName(s).IsNil(), "c.%s should not be nil", s) + } +} + +func testClientDefaultUserAgent(t *testing.T, c *Client) { + expectedUserAgent := fmt.Sprintf("golang:%s:v%s", libraryName, libraryVersion) + require.Equal(t, expectedUserAgent, c.UserAgent()) +} + +func testClientDefaults(t *testing.T, c *Client) { + testClientDefaultUserAgent(t, c) + testClientServices(t, c) +} + +func TestNewClient(t *testing.T) { + c, err := NewClient(Credentials{}) + require.NoError(t, err) + testClientDefaults(t, c) +} + +func TestNewClient_Error(t *testing.T) { + _, err := NewClient(Credentials{}) + require.NoError(t, err) + + errorOpt := func(c *Client) error { + return errors.New("foo") + } + + _, err = NewClient(Credentials{}, errorOpt) + require.EqualError(t, err, "foo") +} + +func TestNewReadonlyClient(t *testing.T) { + c, err := NewReadonlyClient() + require.NoError(t, err) + require.Equal(t, c.BaseURL.String(), defaultBaseURLReadonly) +} + +func TestNewReadonlyClient_Error(t *testing.T) { + _, err := NewReadonlyClient() + require.NoError(t, err) + + errorOpt := func(c *Client) error { + return errors.New("foo") + } + + _, err = NewReadonlyClient(errorOpt) + require.EqualError(t, err, "foo") +} + +func TestDefaultClient(t *testing.T) { + require.NotNil(t, DefaultClient()) +} + +func TestClient_Readonly_NewRequest(t *testing.T) { + c, err := NewReadonlyClient() + require.NoError(t, err) + + req, err := c.NewRequest(http.MethodGet, "r/golang", nil) + require.NoError(t, err) + require.Equal(t, defaultBaseURLReadonly+"/r/golang.json", req.URL.String()) +} + +func TestClient_OnRequestComplemented(t *testing.T) { + client, mux := setup(t) + + var i int + cb := func(*http.Request, *http.Response) { + i++ + } + client.OnRequestCompleted(cb) + + mux.HandleFunc("/api/v1/test", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + }) + + req, err := client.NewRequest(http.MethodGet, "api/v1/test", nil) + require.NoError(t, err) + + _, _ = client.Do(ctx, req, nil) + require.Equal(t, 1, i) + + _, _ = client.Do(ctx, req, nil) + _, _ = client.Do(ctx, req, nil) + _, _ = client.Do(ctx, req, nil) + _, _ = client.Do(ctx, req, nil) + require.Equal(t, 5, i) + + _, _ = client.Do(ctx, req, nil) + require.Equal(t, 6, i) +} + +func TestClient_JSONErrorResponse(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/test", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, `{ + "json": { + "errors": [ + [ + "TEST_ERROR", + "this is a test error", + "test field" + ] + ] + } + }`) + }) + + req, err := client.NewRequest(http.MethodGet, "api/v1/test", nil) + require.NoError(t, err) + + resp, err := client.Do(ctx, req, nil) + require.IsType(t, &JSONErrorResponse{}, err) + require.EqualError(t, err, fmt.Sprintf(`GET %s/api/v1/test: 200 field "test field" caused TEST_ERROR: this is a test error`, client.BaseURL)) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestClient_ErrorResponse(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/test", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{ + "message": "error message" + }`) + }) + + req, err := client.NewRequest(http.MethodGet, "api/v1/test", nil) + require.NoError(t, err) + + resp, err := client.Do(ctx, req, nil) + require.IsType(t, &ErrorResponse{}, err) + require.EqualError(t, err, fmt.Sprintf(`GET %s/api/v1/test: 403 error message`, client.BaseURL)) + require.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestClient_Do_RateLimitError(t *testing.T) { + client, mux := setup(t) + + var counter int + mux.HandleFunc("/api/v1/test", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + defer func() { counter++ }() + + switch counter { + case 0: + w.Header().Set(headerRateLimitRemaining, "500") + w.Header().Set(headerRateLimitUsed, "100") + w.Header().Set(headerRateLimitReset, "120") + case 1: + w.Header().Set(headerRateLimitRemaining, "0") + w.Header().Set(headerRateLimitUsed, "600") + w.Header().Set(headerRateLimitReset, "240") + } + }) + + req, err := client.NewRequest(http.MethodGet, "api/v1/test", nil) + require.NoError(t, err) + + client.rate.Remaining = 0 + client.rate.Reset = time.Now().Add(time.Minute) + + resp, err := client.Do(ctx, req, nil) + require.Equal(t, 0, counter) + require.IsType(t, &RateLimitError{}, err) + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + + client.rate = Rate{} + + resp, err = client.Do(ctx, req, nil) + require.Equal(t, 1, counter) + require.Nil(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 500, resp.Rate.Remaining) + require.Equal(t, 100, resp.Rate.Used) + require.Equal(t, time.Now().Truncate(time.Second).Add(time.Minute*2), resp.Rate.Reset) + + resp, err = client.Do(ctx, req, nil) + require.Equal(t, 2, counter) + require.IsType(t, &RateLimitError{}, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 0, resp.Rate.Remaining) + require.Equal(t, 600, resp.Rate.Used) + require.Equal(t, time.Now().Truncate(time.Second).Add(time.Minute*4), resp.Rate.Reset) +} + +func TestClient_Do_Gzip(t *testing.T) { + client, mux := setup(t) + expected := map[string]string{ + "message": "Hello!", + } + mux.HandleFunc("/api/v1/test", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Encoding", "gzip") + b, _ := json.Marshal(&expected) + zw := gzip.NewWriter(w) + zw.Write(b) + zw.Close() + }) + req, err := client.NewRequest(http.MethodGet, "api/v1/test", nil) + require.NoError(t, err) + result := map[string]string{} + _, err = client.Do(ctx, req, &result) + require.NoError(t, err) + require.Equal(t, result, expected) +} diff --git a/v2/reddit/stream.go b/v2/reddit/stream.go new file mode 100644 index 0000000..09c4d32 --- /dev/null +++ b/v2/reddit/stream.go @@ -0,0 +1,116 @@ +package reddit + +import ( + "context" + "sync" + "time" +) + +// StreamService allows streaming new content from Reddit as it appears. +type StreamService struct { + client *Client +} + +// Posts streams posts from the specified subreddit. +// It returns 2 channels and a function: +// - a channel into which new posts will be sent +// - a channel into which any errors will be sent +// - a function that the client can call once to stop the streaming and close the channels +// +// Because of the 100 post limit imposed by Reddit when fetching posts, some high-traffic +// streams might drop submissions between API requests, such as when streaming r/all. +func (s *StreamService) Posts(subreddit string, opts ...StreamOpt) (<-chan *Post, <-chan error, func()) { + streamConfig := &streamConfig{ + Interval: defaultStreamInterval, + DiscardInitial: false, + MaxRequests: 0, + } + for _, opt := range opts { + opt(streamConfig) + } + + ticker := time.NewTicker(streamConfig.Interval) + postsCh := make(chan *Post) + errsCh := make(chan error) + + var once sync.Once + stop := func() { + once.Do(func() { + ticker.Stop() + close(postsCh) + close(errsCh) + }) + } + + // originally used the "before" parameter, but if that post gets deleted, subsequent requests + // would just return empty listings; easier to just keep track of all post ids encountered + ids := set{} + + go func() { + defer stop() + + var n int + infinite := streamConfig.MaxRequests == 0 + + for ; ; <-ticker.C { + n++ + + posts, err := s.getPosts(subreddit) + if err != nil { + errsCh <- err + if !infinite && n >= streamConfig.MaxRequests { + break + } + continue + } + + for _, post := range posts { + id := post.FullID + + // if this post id is already part of the set, it means that it and the ones + // after it in the list have already been streamed, so break out of the loop + if ids.Exists(id) { + break + } + ids.Add(id) + + if streamConfig.DiscardInitial { + streamConfig.DiscardInitial = false + break + } + + postsCh <- post + } + + if !infinite && n >= streamConfig.MaxRequests { + break + } + } + }() + + return postsCh, errsCh, stop +} + +func (s *StreamService) getPosts(subreddit string) ([]*Post, error) { + posts, _, err := s.client.Subreddit.NewPosts(context.Background(), subreddit, &ListOptions{Limit: 100}) + return posts, err +} + +type set map[string]struct{} + +func (s set) Add(v string) { + s[v] = struct{}{} +} + +func (s set) Delete(v string) { + delete(s, v) +} + +func (s set) Len() int { + return len(s) +} + +func (s set) Exists(v string) bool { + _, ok := s[v] + return ok +} diff --git a/v2/reddit/stream_test.go b/v2/reddit/stream_test.go new file mode 100644 index 0000000..9ae48c6 --- /dev/null +++ b/v2/reddit/stream_test.go @@ -0,0 +1,310 @@ +package reddit + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStreamService_Posts(t *testing.T) { + client, mux := setup(t) + + var counter int + mux.HandleFunc("/r/testsubreddit/new", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + defer func() { counter++ }() + + switch counter { + case 0: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t3", + "data": { + "name": "t3_post1" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post2" + } + } + ] + } + }`) + case 1: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t3", + "data": { + "name": "t3_post3" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post1" + } + } + ] + } + }`) + case 2: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t3", + "data": { + "name": "t3_post4" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post5" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post6" + } + } + ] + } + }`) + case 3: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t3", + "data": { + "name": "t3_post7" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post8" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post9" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post10" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post11" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post12" + } + } + ] + } + }`) + default: + fmt.Fprint(w, `{}`) + } + }) + + posts, errs, stop := client.Stream.Posts("testsubreddit", StreamInterval(time.Millisecond*10), StreamMaxRequests(4)) + defer stop() + + expectedPostIDs := []string{"t3_post1", "t3_post2", "t3_post3", "t3_post4", "t3_post5", "t3_post6", "t3_post7", "t3_post8", "t3_post9", "t3_post10", "t3_post11", "t3_post12"} + var i int + +loop: + for i != len(expectedPostIDs) { + select { + case post, ok := <-posts: + if !ok { + break loop + } + require.Equal(t, expectedPostIDs[i], post.FullID) + case err, ok := <-errs: + if !ok { + break loop + } + require.NoError(t, err) + } + i++ + } + + require.Len(t, expectedPostIDs, i) +} + +func TestStreamService_Posts_DiscardInitial(t *testing.T) { + client, mux := setup(t) + + var counter int + mux.HandleFunc("/r/testsubreddit/new", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + defer func() { counter++ }() + + switch counter { + case 0: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t3", + "data": { + "name": "t3_post1" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post2" + } + } + ] + } + }`) + case 1: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t3", + "data": { + "name": "t3_post3" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post1" + } + } + ] + } + }`) + case 2: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t3", + "data": { + "name": "t3_post4" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post5" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post6" + } + } + ] + } + }`) + case 3: + fmt.Fprint(w, `{ + "kind": "Listing", + "data": { + "children": [ + { + "kind": "t3", + "data": { + "name": "t3_post7" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post8" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post9" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post10" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post11" + } + }, + { + "kind": "t3", + "data": { + "name": "t3_post12" + } + } + ] + } + }`) + default: + fmt.Fprint(w, `{}`) + } + }) + + posts, errs, stop := client.Stream.Posts("testsubreddit", StreamInterval(time.Millisecond*10), StreamMaxRequests(4), StreamDiscardInitial) + defer stop() + + expectedPostIDs := []string{"t3_post3", "t3_post4", "t3_post5", "t3_post6", "t3_post7", "t3_post8", "t3_post9", "t3_post10", "t3_post11", "t3_post12"} + var i int + +loop: + for i != len(expectedPostIDs) { + select { + case post, ok := <-posts: + if !ok { + break loop + } + require.Equal(t, expectedPostIDs[i], post.FullID) + case err, ok := <-errs: + if !ok { + break loop + } + require.NoError(t, err) + } + i++ + } + + require.Len(t, expectedPostIDs, i) +} diff --git a/v2/reddit/streamer.go b/v2/reddit/streamer.go new file mode 100644 index 0000000..2216ab8 --- /dev/null +++ b/v2/reddit/streamer.go @@ -0,0 +1,44 @@ +package reddit + +import "time" + +const defaultStreamInterval = time.Second * 5 + +type streamConfig struct { + Interval time.Duration + DiscardInitial bool + MaxRequests int +} + +// StreamOpt is a configuration option to configure a stream. +type StreamOpt func(*streamConfig) + +// StreamInterval sets the frequency at which data will be fetched for the stream. +// If the duration is 0 or less, it will not be set and the default will be used. +func StreamInterval(v time.Duration) StreamOpt { + return func(c *streamConfig) { + if v > 0 { + c.Interval = v + } + } +} + +// StreamDiscardInitial will discard data from the first fetch for the stream. +func StreamDiscardInitial(c *streamConfig) { + c.DiscardInitial = true +} + +// StreamMaxRequests sets a limit on the number of times data is fetched for a stream. +// If less than or equal to 0, it is assumed to be infinite. +func StreamMaxRequests(v int) StreamOpt { + return func(c *streamConfig) { + if v > 0 { + c.MaxRequests = v + } + } +} + +// Streamer streams data to the client. +// type Streamer interface { +// Stream() (<-chan *rootListing, <-chan error, func()) +// } diff --git a/v2/reddit/subreddit.go b/v2/reddit/subreddit.go new file mode 100644 index 0000000..dc94838 --- /dev/null +++ b/v2/reddit/subreddit.go @@ -0,0 +1,1182 @@ +package reddit + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/google/go-querystring/query" +) + +// SubredditService handles communication with the subreddit +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_subreddits +type SubredditService struct { + client *Client +} + +type rootSubredditNames struct { + Names []string `json:"names,omitempty"` +} + +// Relationship holds information about a relationship (friend/blocked). +type Relationship struct { + ID string `json:"rel_id,omitempty"` + User string `json:"name,omitempty"` + UserID string `json:"id,omitempty"` + Created *Timestamp `json:"date,omitempty"` +} + +// Moderator is a user who moderates a subreddit. +type Moderator struct { + *Relationship + Permissions []string `json:"mod_permissions"` +} + +// Ban represents a banned relationship. +type Ban struct { + *Relationship + // nil means the ban is permanent + DaysLeft *int `json:"days_left"` + Note string `json:"note,omitempty"` +} + +// SubredditRule is a rule in the subreddit. +type SubredditRule struct { + // One of: comment, link (i.e. post), or all (i.e. both comment and link). + Kind string `json:"kind,omitempty"` + // Short description of the rule. + Name string `json:"short_name,omitempty"` + // The reason that will appear when a thing is reported in violation to this rule. + ViolationReason string `json:"violation_reason,omitempty"` + Description string `json:"description,omitempty"` + Priority int `json:"priority"` + Created *Timestamp `json:"created_utc,omitempty"` +} + +// SubredditRuleCreateRequest represents a request to add a subreddit rule. +type SubredditRuleCreateRequest struct { + // One of: comment, link (i.e. post) or all (i.e. both). + Kind string `url:"kind"` + // Short description of the rule. No longer than 100 characters. + Name string `url:"short_name"` + // The reason that will appear when a thing is reported in violation to this rule. + // If this is empty, Reddit will set its value to Name by default. + // No longer than 100 characters. + ViolationReason string `url:"violation_reason,omitempty"` + // Optional. No longer than 500 characters. + Description string `url:"description,omitempty"` +} + +func (r *SubredditRuleCreateRequest) validate() error { + if r == nil { + return errors.New("*SubredditRuleCreateRequest: cannot be nil") + } + + switch r.Kind { + case "comment", "link", "all": + // intentionally left blank + default: + return errors.New("(*SubredditRuleCreateRequest).Kind: must be one of: comment, link, all") + } + + if r.Name == "" || len(r.Name) > 100 { + return errors.New("(*SubredditRuleCreateRequest).Name: must be between 1-100 characters") + } + + if len(r.ViolationReason) > 100 { + return errors.New("(*SubredditRuleCreateRequest).ViolationReason: cannot be longer than 100 characters") + } + + if len(r.Description) > 500 { + return errors.New("(*SubredditRuleCreateRequest).Description: cannot be longer than 500 characters") + } + + return nil +} + +// SubredditTrafficStats hold information about subreddit traffic. +type SubredditTrafficStats struct { + // Traffic data is returned in the form of day, hour, and month. + // Start is a timestamp indicating the start of the category, i.e. + // start of the day for day, start of the hour for hour, and start of the month for month. + Start *Timestamp `json:"start"` + UniqueViews int `json:"unique_views"` + TotalViews int `json:"total_views"` + // This is only available for "day" traffic, not hour and month. + // Therefore, it is always 0 by default for hour and month. + Subscribers int `json:"subscribers"` +} + +// SubredditImage is an image part of the image set of a subreddit. +type SubredditImage struct { + Name string `json:"name"` + Link string `json:"link"` + URL string `json:"url"` +} + +// SubredditStyleSheet contains the subreddit's styling information. +type SubredditStyleSheet struct { + SubredditID string `json:"subreddit_id"` + Images []*SubredditImage `json:"images"` + StyleSheet string `json:"stylesheet"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (s *SubredditTrafficStats) UnmarshalJSON(b []byte) error { + var data [4]int + err := json.Unmarshal(b, &data) + if err != nil { + return err + } + + timestampByteValue, err := json.Marshal(data[0]) + if err != nil { + return err + } + + timestamp := new(Timestamp) + err = timestamp.UnmarshalJSON(timestampByteValue) + if err != nil { + return err + } + + s.Start = timestamp + s.UniqueViews = data[1] + s.TotalViews = data[2] + s.Subscribers = data[3] + + return nil +} + +// SubredditSettings are a subreddit's settings. +type SubredditSettings struct { + // The id of the subreddit. + ID string `url:"-" json:"subreddit_id,omitempty"` + + // One of: public, restricted, private, gold_restricted, archived, employees_only, gold_only, user. + Type *string `url:"type,omitempty" json:"subreddit_type,omitempty"` + + // A valid IETF language tag (underscore separated). + Language *string `url:"lang,omitempty" json:"language,omitempty"` + + // No longer than 100 characters. + Title *string `url:"title,omitempty" json:"title,omitempty"` + // Raw markdown text. No longer than 500 characters. + Description *string `url:"public_description,omitempty" json:"public_description,omitempty"` + // Raw markdown text. No longer than 10240 characters. + Sidebar *string `url:"description,omitempty" json:"description,omitempty"` + // Raw markdown text. No longer than 1024 characters. + SubmissionText *string `url:"submit_text,omitempty" json:"submit_text,omitempty"` + // Raw markdown text. No longer than 5000 characters. + WelcomeMessage *string `url:"welcome_message_text,omitempty" json:"welcome_message_text,omitempty"` + WelcomeMessageEnabled *bool `url:"welcome_message_enabled,omitempty" json:"welcome_message_enabled,omitempty"` + + AllowCrossposts *bool `url:"allow_post_crossposts,omitempty" json:"allow_post_crossposts,omitempty"` + AllowChatPosts *bool `url:"allow_chat_post_creation,omitempty" json:"allow_chat_post_creation,omitempty"` + AllowPollPosts *bool `url:"allow_polls,omitempty" json:"allow_polls,omitempty"` + AllowFreeFormReports *bool `url:"free_form_reports,omitempty" json:"free_form_reports,omitempty"` + AllowOriginalContent *bool `url:"original_content_tag_enabled,omitempty" json:"original_content_tag_enabled,omitempty"` + // Allow image uploads and links to image hosting sites. + AllowImages *bool `url:"allow_images,omitempty" json:"allow_images,omitempty"` + AllowMultipleImagesPerPost *bool `url:"allow_galleries,omitempty" json:"allow_galleries,omitempty"` + + ExcludeSitewideBannedUsersContent *bool `url:"exclude_banned_modqueue,omitempty" json:"exclude_banned_modqueue,omitempty"` + + // An integer from 0 to 3. + CrowdControlChalLevel *int `url:"crowd_control_chat_level,omitempty" json:"crowd_control_chat_level,omitempty"` + + // Mark all posts in this subreddit as Original Content (OC) on the desktop redesign. + AllOriginalContent *bool `url:"all_original_content,omitempty" json:"all_original_content,omitempty"` + + // One of: none (recommended), confidence, top, new, controversial, old, random, qa, live. + SuggestedCommentSort *string `url:"suggested_comment_sort,omitempty" json:"suggested_comment_sort,omitempty"` + + // No longer than 60 characters. + SubmitLinkPostLabel *string `url:"submit_link_label,omitempty" json:"submit_link_label,omitempty"` + // No longer than 60 characters. + SubmitTextPostLabel *string `url:"submit_text_label,omitempty" json:"submit_text_label,omitempty"` + + // One of: any, link, self. + PostType *string `url:"link_type,omitempty" json:"content_options,omitempty"` + + // One of: low (disable most filtering), high (standard), all (filter everything, requiring mod approval). + SpamFilterStrengthLinkPosts *string `url:"spam_links,omitempty" json:"spam_links,omitempty"` + // One of: low (disable most filtering), high (standard), all (filter everything, requiring mod approval). + SpamFilterStrengthTextPosts *string `url:"spam_selfposts,omitempty" json:"spam_selfposts,omitempty"` + // One of: low (disable most filtering), high (standard), all (filter everything, requiring mod approval). + SpamFilterStrengthComments *string `url:"spam_comments,omitempty" json:"spam_comments,omitempty"` + + ShowContentThumbnails *bool `url:"show_media,omitempty" json:"show_media,omitempty"` + ExpandMediaPreviewsOnCommentsPages *bool `url:"show_media_preview,omitempty" json:"show_media_preview,omitempty"` + + CollapseDeletedComments *bool `url:"collapse_deleted_comments,omitempty" json:"collapse_deleted_comments,omitempty"` + // An integer between 0 and 1440. + MinutesToHideCommentScores *int `url:"comment_score_hide_mins,omitempty" json:"comment_score_hide_mins,omitempty"` + + // Enable marking posts as containing spoilers. + SpoilersEnabled *bool `url:"spoilers_enabled,omitempty" json:"spoilers_enabled,omitempty"` + + // If there's an image header set, hovering the mouse over it will display this text. + HeaderMouseoverText *string `url:"header-title,omitempty" json:"header_hover_text,omitempty"` + + // 6-digit rgb hex colour, e.g. #AABBCC. + // Thematic colour for the subreddit on mobile. + MobileColour *string `url:"key_color,omitempty" json:"key_color,omitempty"` + + // Can only be set to true if subreddit type is gold_only. + HideAds *bool `url:"hide_ads,omitempty" json:"hide_ads,omitempty"` + // Require viewers to be over 18 years old. + NSFW *bool `url:"over_18,omitempty" json:"over_18,omitempty"` + + // Show up in high-traffic feeds: Allow your community to be in r/all, r/popular, and trending lists where it can be + // seen by the general Reddit population. + AllowDiscoveryInHighTrafficFeeds *bool `url:"allow_top,omitempty" json:"default_set,omitempty"` + // Get recommended to individual redditors. Let Reddit recommend your community to people who have similar interests. + AllowDiscoveryByIndividualUsers *bool `url:"allow_discovery,omitempty" json:"allow_discovery,omitempty"` + + // One of: + // - disabled: wiki is disabled for everyone except mods. + // - modonly: only mods, approved wiki contributors, or those on a page's edit list may edit. + // - anyone: anyone who can submit to the subreddit may edit. + WikiMode *string `url:"wikimode,omitempty" json:"wikimode,omitempty"` + // Account age (in days) required to create and edit wiki pages. + WikiMinimumAccountAge *int `url:"wiki_edit_age,omitempty" json:"wiki_edit_age,omitempty"` + // Subreddit karma required to create and edit wiki pages. + WikiMinimumKarma *int `url:"wiki_edit_karma,omitempty" json:"wiki_edit_karma,omitempty"` +} + +// SubredditPostRequirements is a list of moderator-designed requirements to post to a subreddit. +type SubredditPostRequirements struct { + // Sentence or two on how to successfully post to the subreddit. + Guidelines string `json:"guidelines_text"` + GuidelinesDisplayPolicy string `json:"guidelines_display_policy"` + + TitleMinLength int `json:"title_text_min_length"` + TitleMaxLength int `json:"title_text_max_length"` + + BodyMinLength int `json:"body_text_min_length"` + BodyMaxLength int `json:"body_text_max_length"` + + // Do not allow any of these words in the title. + TitleBlacklistedStrings []string `json:"title_blacklisted_strings"` + // Do not allow any of these words in the body. + BodyBlacklistedStrings []string `json:"body_blacklisted_strings"` + + // Require at least one of these words in the title. + TitleRequiredStrings []string `json:"title_required_strings"` + // Require at least one of these words in the body. + BodyRequiredStrings []string `json:"body_required_strings"` + + // Block link posts with these domains. + DomainBlacklist []string `json:"domain_blacklist"` + // Only allow link posts with these domains. + DomainWhitelist []string `json:"domain_whitelist"` + + // One of: required, notAllowed, none. + // Meaning that a selftext post's body is required, not allowed, or optional, respectively. + BodyRestrictionPolicy string `json:"body_restriction_policy"` + LinkRestrictionPolicy string `json:"link_restriction_policy"` + + GalleryMinItems int `json:"gallery_min_items"` + GalleryMaxItems int `json:"gallery_max_items"` + GalleryCaptionsRequirement string `json:"gallery_captions_requirement"` + GalleryURLsRequirement string `json:"gallery_urls_requirement"` + + // Prevent users from posting a link that was already posted to your community within x days of the original. + LinkRepostAge int `json:"link_repost_age"` + FlairRequired bool `json:"is_flair_required"` + + TitleRegexes []string `json:"title_regexes"` + BodyRegexes []string `json:"body_regexes"` +} + +func (s *SubredditService) getPosts(ctx context.Context, sort string, subreddit string, opts interface{}) ([]*Post, *Response, error) { + path := sort + if subreddit != "" { + path = fmt.Sprintf("r/%s/%s", subreddit, sort) + } + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} + +// HotPosts returns the hottest posts from the specified subreddit. +// To search through multiple, separate the names with a plus (+), e.g. "golang+test". +// If none are defined, it returns the ones from your subscribed subreddits. +// To search through all, just specify "all". +// To search through all and filter out subreddits, provide "all-name1-name2". +// Note: when looking for hot posts in a subreddit, it will include the stickied +// posts (if any) PLUS posts from the limit parameter (25 by default). +func (s *SubredditService) HotPosts(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, *Response, error) { + return s.getPosts(ctx, "hot", subreddit, opts) +} + +// NewPosts returns the newest posts from the specified subreddit. +// To search through multiple, separate the names with a plus (+), e.g. "golang+test". +// If none are defined, it returns the ones from your subscribed subreddits. +// To search through all, just specify "all". +// To search through all and filter out subreddits, provide "all-name1-name2". +func (s *SubredditService) NewPosts(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, *Response, error) { + return s.getPosts(ctx, "new", subreddit, opts) +} + +// RisingPosts returns the rising posts from the specified subreddit. +// To search through multiple, separate the names with a plus (+), e.g. "golang+test". +// If none are defined, it returns the ones from your subscribed subreddits. +// To search through all, just specify "all". +// To search through all and filter out subreddits, provide "all-name1-name2". +func (s *SubredditService) RisingPosts(ctx context.Context, subreddit string, opts *ListOptions) ([]*Post, *Response, error) { + return s.getPosts(ctx, "rising", subreddit, opts) +} + +// ControversialPosts returns the most controversial posts from the specified subreddit. +// To search through multiple, separate the names with a plus (+), e.g. "golang+test". +// If none are defined, it returns the ones from your subscribed subreddits. +// To search through all, just specify "all". +// To search through all and filter out subreddits, provide "all-name1-name2". +func (s *SubredditService) ControversialPosts(ctx context.Context, subreddit string, opts *ListPostOptions) ([]*Post, *Response, error) { + return s.getPosts(ctx, "controversial", subreddit, opts) +} + +// TopPosts returns the top posts from the specified subreddit. +// To search through multiple, separate the names with a plus (+), e.g. "golang+test". +// If none are defined, it returns the ones from your subscribed subreddits. +// To search through all, just specify "all". +// To search through all and filter out subreddits, provide "all-name1-name2". +func (s *SubredditService) TopPosts(ctx context.Context, subreddit string, opts *ListPostOptions) ([]*Post, *Response, error) { + return s.getPosts(ctx, "top", subreddit, opts) +} + +// Get a subreddit by name. +func (s *SubredditService) Get(ctx context.Context, name string) (*Subreddit, *Response, error) { + if name == "" { + return nil, nil, errors.New("name: cannot be empty") + } + + path := fmt.Sprintf("r/%s/about", name) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + + sr, _ := t.Subreddit() + return sr, resp, nil +} + +// Popular returns popular subreddits. +func (s *SubredditService) Popular(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) { + return s.getSubreddits(ctx, "subreddits/popular", opts) +} + +// New returns new subreddits. +func (s *SubredditService) New(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) { + return s.getSubreddits(ctx, "subreddits/new", opts) +} + +// Gold returns gold subreddits (i.e. only accessible to users with gold). +// It seems like it returns an empty list if you don't have gold. +func (s *SubredditService) Gold(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) { + return s.getSubreddits(ctx, "subreddits/gold", opts) +} + +// Default returns default subreddits. +func (s *SubredditService) Default(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) { + return s.getSubreddits(ctx, "subreddits/default", opts) +} + +// Subscribed returns the list of subreddits you are subscribed to. +func (s *SubredditService) Subscribed(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) { + return s.getSubreddits(ctx, "subreddits/mine/subscriber", opts) +} + +// Approved returns the list of subreddits you are an approved user in. +func (s *SubredditService) Approved(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) { + return s.getSubreddits(ctx, "subreddits/mine/contributor", opts) +} + +// Moderated returns the list of subreddits you are a moderator of. +func (s *SubredditService) Moderated(ctx context.Context, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) { + return s.getSubreddits(ctx, "subreddits/mine/moderator", opts) +} + +// GetSticky1 returns the first stickied post on a subreddit (if it exists). +func (s *SubredditService) GetSticky1(ctx context.Context, subreddit string) (*PostAndComments, *Response, error) { + return s.getSticky(ctx, subreddit, 1) +} + +// GetSticky2 returns the second stickied post on a subreddit (if it exists). +func (s *SubredditService) GetSticky2(ctx context.Context, subreddit string) (*PostAndComments, *Response, error) { + return s.getSticky(ctx, subreddit, 2) +} + +func (s *SubredditService) handleSubscription(ctx context.Context, form url.Values) (*Response, error) { + path := "api/subscribe" + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +// Subscribe subscribes to subreddits based on their names. +func (s *SubredditService) Subscribe(ctx context.Context, subreddits ...string) (*Response, error) { + form := url.Values{} + form.Set("action", "sub") + form.Set("sr_name", strings.Join(subreddits, ",")) + return s.handleSubscription(ctx, form) +} + +// SubscribeByID subscribes to subreddits based on their id. +func (s *SubredditService) SubscribeByID(ctx context.Context, ids ...string) (*Response, error) { + form := url.Values{} + form.Set("action", "sub") + form.Set("sr", strings.Join(ids, ",")) + return s.handleSubscription(ctx, form) +} + +// Unsubscribe unsubscribes from subreddits based on their names. +func (s *SubredditService) Unsubscribe(ctx context.Context, subreddits ...string) (*Response, error) { + form := url.Values{} + form.Set("action", "unsub") + form.Set("sr_name", strings.Join(subreddits, ",")) + return s.handleSubscription(ctx, form) +} + +// UnsubscribeByID unsubscribes from subreddits based on their id. +func (s *SubredditService) UnsubscribeByID(ctx context.Context, ids ...string) (*Response, error) { + form := url.Values{} + form.Set("action", "unsub") + form.Set("sr", strings.Join(ids, ",")) + return s.handleSubscription(ctx, form) +} + +// Favorite the subreddit. +func (s *SubredditService) Favorite(ctx context.Context, subreddit string) (*Response, error) { + path := "api/favorite" + + form := url.Values{} + form.Set("sr_name", subreddit) + form.Set("make_favorite", "true") + form.Set("api_type", "json") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Unfavorite the subreddit. +func (s *SubredditService) Unfavorite(ctx context.Context, subreddit string) (*Response, error) { + path := "api/favorite" + + form := url.Values{} + form.Set("sr_name", subreddit) + form.Set("make_favorite", "false") + form.Set("api_type", "json") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Search for subreddits. +func (s *SubredditService) Search(ctx context.Context, query string, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) { + path := fmt.Sprintf("subreddits/search?q=%s", query) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Subreddits(), resp, nil +} + +// SearchNames searches for subreddits with names beginning with the query provided. +func (s *SubredditService) SearchNames(ctx context.Context, query string) ([]string, *Response, error) { + path := fmt.Sprintf("api/search_reddit_names?query=%s", query) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(rootSubredditNames) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Names, resp, nil +} + +// SearchPosts searches for posts in the specified subreddit. +// To search through multiple, separate the names with a plus (+), e.g. "golang+test". +// If no subreddit is provided, the search is run against r/all. +func (s *SubredditService) SearchPosts(ctx context.Context, query string, subreddit string, opts *ListPostSearchOptions) ([]*Post, *Response, error) { + if subreddit == "" { + subreddit = "all" + } + + path := fmt.Sprintf("r/%s/search", subreddit) + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + notAll := !strings.EqualFold(subreddit, "all") + + params := struct { + Query string `url:"q"` + RestrictSubreddits bool `url:"restrict_sr,omitempty"` + }{query, notAll} + + t, resp, err := s.client.getThing(ctx, path, params) + if err != nil { + return nil, resp, err + } + + l, _ := t.Listing() + return l.Posts(), resp, nil +} + +func (s *SubredditService) getSubreddits(ctx context.Context, path string, opts *ListSubredditOptions) ([]*Subreddit, *Response, error) { + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Subreddits(), resp, nil +} + +// getSticky returns one of the 2 stickied posts of the subreddit (if they exist). +// Num should be equal to 1 or 2, depending on which one you want. +func (s *SubredditService) getSticky(ctx context.Context, subreddit string, num int) (*PostAndComments, *Response, error) { + params := struct { + Num int `url:"num"` + }{num} + + path := fmt.Sprintf("r/%s/about/sticky", subreddit) + path, err := addOptions(path, params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(PostAndComments) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// todo: sr_detail's NSFW indicator is over_18 instead of over18 +func (s *SubredditService) random(ctx context.Context, nsfw bool) (*Subreddit, *Response, error) { + path := "r/random" + if nsfw { + path = "r/randnsfw" + } + + params := struct { + ExpandSubreddit bool `url:"sr_detail"` + Limit int `url:"limit,omitempty"` + }{true, 1} + + path, err := addOptions(path, params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Data struct { + Children [1]struct { + Data struct { + Subreddit *Subreddit `json:"sr_detail"` + } `json:"data"` + } `json:"children"` + } `json:"data"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + subreddit := root.Data.Children[0].Data.Subreddit + return subreddit, resp, nil +} + +// Random returns a random SFW subreddit. +func (s *SubredditService) Random(ctx context.Context) (*Subreddit, *Response, error) { + return s.random(ctx, false) +} + +// RandomNSFW returns a random NSFW subreddit. +func (s *SubredditService) RandomNSFW(ctx context.Context) (*Subreddit, *Response, error) { + return s.random(ctx, true) +} + +// SubmissionText gets the submission text for the subreddit. +// This text is set by the subreddit moderators and intended to be displayed on the submission form. +func (s *SubredditService) SubmissionText(ctx context.Context, name string) (string, *Response, error) { + if name == "" { + return "", nil, errors.New("name: cannot be empty") + } + + path := fmt.Sprintf("r/%s/api/submit_text", name) + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return "", nil, err + } + + root := new(struct { + Text string `json:"submit_text"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return "", resp, err + } + + return root.Text, resp, err +} + +// Banned gets banned users from the subreddit. +func (s *SubredditService) Banned(ctx context.Context, subreddit string, opts *ListOptions) ([]*Ban, *Response, error) { + path := fmt.Sprintf("r/%s/about/banned", subreddit) + + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Data struct { + Bans []*Ban `json:"children"` + After string `json:"after"` + } `json:"data"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + resp.After = root.Data.After + return root.Data.Bans, resp, nil +} + +// Muted gets muted users from the subreddit. +func (s *SubredditService) Muted(ctx context.Context, subreddit string, opts *ListOptions) ([]*Relationship, *Response, error) { + path := fmt.Sprintf("r/%s/about/muted", subreddit) + + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Data struct { + Relationships []*Relationship `json:"children"` + After string `json:"after"` + } `json:"data"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + resp.After = root.Data.After + return root.Data.Relationships, resp, nil +} + +// WikiBanned gets banned users from the subreddit. +func (s *SubredditService) WikiBanned(ctx context.Context, subreddit string, opts *ListOptions) ([]*Ban, *Response, error) { + path := fmt.Sprintf("r/%s/about/wikibanned", subreddit) + + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Data struct { + Bans []*Ban `json:"children"` + After string `json:"after"` + } `json:"data"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + resp.After = root.Data.After + return root.Data.Bans, resp, nil +} + +// Contributors gets contributors (also known as approved users) from the subreddit. +func (s *SubredditService) Contributors(ctx context.Context, subreddit string, opts *ListOptions) ([]*Relationship, *Response, error) { + path := fmt.Sprintf("r/%s/about/contributors", subreddit) + + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Data struct { + Relationships []*Relationship `json:"children"` + After string `json:"after"` + } `json:"data"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + resp.After = root.Data.After + return root.Data.Relationships, resp, nil +} + +// WikiContributors gets contributors of the wiki from the subreddit. +func (s *SubredditService) WikiContributors(ctx context.Context, subreddit string, opts *ListOptions) ([]*Relationship, *Response, error) { + path := fmt.Sprintf("r/%s/about/wikicontributors", subreddit) + + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Data struct { + Relationships []*Relationship `json:"children"` + After string `json:"after"` + } `json:"data"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + resp.After = root.Data.After + return root.Data.Relationships, resp, nil +} + +// Moderators gets the moderators of the subreddit. +func (s *SubredditService) Moderators(ctx context.Context, subreddit string) ([]*Moderator, *Response, error) { + path := fmt.Sprintf("r/%s/about/moderators", subreddit) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Data struct { + Moderators []*Moderator `json:"children"` + } `json:"data"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Data.Moderators, resp, nil +} + +// Rules gets the rules of the subreddit. +func (s *SubredditService) Rules(ctx context.Context, subreddit string) ([]*SubredditRule, *Response, error) { + path := fmt.Sprintf("r/%s/about/rules", subreddit) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Rules []*SubredditRule `json:"rules"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Rules, resp, nil +} + +// CreateRule adds a rule to the subreddit. +func (s *SubredditService) CreateRule(ctx context.Context, subreddit string, request *SubredditRuleCreateRequest) (*Response, error) { + err := request.validate() + if err != nil { + return nil, err + } + + form, err := query.Values(request) + if err != nil { + return nil, err + } + form.Set("api_type", "json") + + path := fmt.Sprintf("r/%s/api/add_subreddit_rule", subreddit) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Traffic gets the traffic data of the subreddit. +// It returns traffic data by day, hour, and month, respectively. +func (s *SubredditService) Traffic(ctx context.Context, subreddit string) ([]*SubredditTrafficStats, []*SubredditTrafficStats, []*SubredditTrafficStats, *Response, error) { + path := fmt.Sprintf("r/%s/about/traffic", subreddit) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, nil, nil, err + } + + root := new(struct { + Day []*SubredditTrafficStats `json:"day"` + Hour []*SubredditTrafficStats `json:"hour"` + Month []*SubredditTrafficStats `json:"month"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, nil, nil, resp, err + } + + return root.Day, root.Hour, root.Month, resp, nil +} + +// StyleSheet returns the subreddit's style sheet, as well as some information about images. +func (s *SubredditService) StyleSheet(ctx context.Context, subreddit string) (*SubredditStyleSheet, *Response, error) { + path := fmt.Sprintf("r/%s/about/stylesheet", subreddit) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + styleSheet, _ := t.StyleSheet() + return styleSheet, resp, nil +} + +// StyleSheetRaw returns the subreddit's style sheet with all comments and newlines stripped. +func (s *SubredditService) StyleSheetRaw(ctx context.Context, subreddit string) (string, *Response, error) { + path := fmt.Sprintf("r/%s/stylesheet", subreddit) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return "", nil, err + } + + buf := new(bytes.Buffer) + resp, err := s.client.Do(ctx, req, buf) + if err != nil { + return "", resp, err + } + + return buf.String(), resp, nil +} + +// UpdateStyleSheet updates the style sheet of the subreddit. +// Providing a reason is optional. +func (s *SubredditService) UpdateStyleSheet(ctx context.Context, subreddit, styleSheet, reason string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/subreddit_stylesheet", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("op", "save") + form.Set("stylesheet_contents", styleSheet) + if reason != "" { + form.Set("reason", reason) + } + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// RemoveImage removes an image from the subreddit's custom image set. +// The call succeeds even if the named image does not exist. +func (s *SubredditService) RemoveImage(ctx context.Context, subreddit, imageName string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/delete_sr_img", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("img_name", imageName) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// RemoveHeader removes the subreddit's current header image. +// The call succeeds even if there's no header image. +func (s *SubredditService) RemoveHeader(ctx context.Context, subreddit string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/delete_sr_header", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// RemoveMobileHeader removes the subreddit's current mobile header. +// The call succeeds even if there's no mobile header. +func (s *SubredditService) RemoveMobileHeader(ctx context.Context, subreddit string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/delete_sr_banner", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// RemoveMobileIcon removes the subreddit's current mobile icon. +// The call succeeds even if there's no mobile icon. +func (s *SubredditService) RemoveMobileIcon(ctx context.Context, subreddit string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/delete_sr_icon", subreddit) + + form := url.Values{} + form.Set("api_type", "json") + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +func (s *SubredditService) uploadImage(ctx context.Context, subreddit, imagePath, imageType, imageName string) (string, *Response, error) { + file, err := os.Open(imagePath) + if err != nil { + return "", nil, err + } + defer file.Close() + + form := url.Values{} + form.Set("upload_type", imageType) + form.Set("name", imageName) + form.Set("img_type", "png") + + ext := filepath.Ext(file.Name()) + if strings.EqualFold(ext, ".jpg") { + form.Set("img_type", "jpg") + } + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + + for k := range form { + writer.WriteField(k, form.Get(k)) + } + + part, err := writer.CreateFormFile("file", file.Name()) + if err != nil { + return "", nil, err + } + + _, err = io.Copy(part, file) + if err != nil { + return "", nil, err + } + + err = writer.Close() + if err != nil { + return "", nil, err + } + + path := fmt.Sprintf("r/%s/api/upload_sr_img", subreddit) + u, err := s.client.BaseURL.Parse(path) + if err != nil { + return "", nil, err + } + + req, err := http.NewRequest(http.MethodPost, u.String(), body) + if err != nil { + return "", nil, err + } + req.Header.Set(headerContentType, writer.FormDataContentType()) + + root := new(struct { + Errors []string `json:"errors"` + ErrorValues []string `json:"errors_values"` + ImageSource string `json:"img_src"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return "", resp, err + } + + if len(root.ErrorValues) > 0 { + err = fmt.Errorf("could not upload image: %s", strings.Join(root.ErrorValues, "; ")) + return "", resp, err + } + + return root.ImageSource, resp, nil +} + +// UploadImage uploads an image to the subreddit. +// If an image with the image name already exists, it it replaced. +// A successful call returns a link to the uploaded image. +func (s *SubredditService) UploadImage(ctx context.Context, subreddit, imagePath, imageName string) (string, *Response, error) { + return s.uploadImage(ctx, subreddit, imagePath, "img", imageName) +} + +// UploadHeader uploads an image to be user as the subreddit's header image. +// A successful call returns a link to the uploaded image. +func (s *SubredditService) UploadHeader(ctx context.Context, subreddit, imagePath, imageName string) (string, *Response, error) { + return s.uploadImage(ctx, subreddit, imagePath, "header", imageName) +} + +// UploadMobileHeader uploads an image to be user as the subreddit's mobile header image. +// A successful call returns a link to the uploaded image. +func (s *SubredditService) UploadMobileHeader(ctx context.Context, subreddit, imagePath, imageName string) (string, *Response, error) { + return s.uploadImage(ctx, subreddit, imagePath, "banner", imageName) +} + +// UploadMobileIcon uploads an image to be user as the subreddit's mobile icon. +// A successful call returns a link to the uploaded image. +func (s *SubredditService) UploadMobileIcon(ctx context.Context, subreddit, imagePath, imageName string) (string, *Response, error) { + return s.uploadImage(ctx, subreddit, imagePath, "icon", imageName) +} + +// Create a subreddit. +func (s *SubredditService) Create(ctx context.Context, name string, request *SubredditSettings) (*Response, error) { + if request == nil { + return nil, errors.New("*SubredditSettings: cannot be nil") + } + + form, err := query.Values(request) + if err != nil { + return nil, err + } + form.Set("name", name) + form.Set("api_type", "json") + + path := "api/site_admin" + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Edit a subreddit. +// This endpoint expects all values of the request to be provided. +// To make this easier, it might be useful to get the subreddit's current settings via GetSettings(), +// and use that as a starting point. It even returns the subreddit's ID, which is needed for this. +func (s *SubredditService) Edit(ctx context.Context, subredditID string, request *SubredditSettings) (*Response, error) { + if request == nil { + return nil, errors.New("*SubredditSettings: cannot be nil") + } + + form, err := query.Values(request) + if err != nil { + return nil, err + } + form.Set("sr", subredditID) + form.Set("api_type", "json") + + path := "api/site_admin" + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// GetSettings gets the settings of a subreddit. +func (s *SubredditService) GetSettings(ctx context.Context, subreddit string) (*SubredditSettings, *Response, error) { + path := fmt.Sprintf("r/%s/about/edit", subreddit) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + settings, _ := t.SubredditSettings() + return settings, resp, nil +} + +// PostRequirements returns the subreddit's moderator-designed requirements to post to it. +// Clients may use the values returned by this method to pre-validate submissions to the subreddit. +func (s *SubredditService) PostRequirements(ctx context.Context, subreddit string) (*SubredditPostRequirements, *Response, error) { + path := fmt.Sprintf("api/v1/%s/post_requirements", subreddit) + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(SubredditPostRequirements) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} diff --git a/v2/reddit/subreddit_test.go b/v2/reddit/subreddit_test.go new file mode 100644 index 0000000..9bf9049 --- /dev/null +++ b/v2/reddit/subreddit_test.go @@ -0,0 +1,1722 @@ +package reddit + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedPosts = []*Post{ + { + ID: "agi5zf", + FullID: "t3_agi5zf", + Created: &Timestamp{time.Date(2019, 1, 16, 5, 57, 51, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/test/comments/agi5zf/test/", + URL: "https://www.reddit.com/r/test/comments/agi5zf/test/", + + Title: "test", + Body: "test", + + Score: 253, + UpvoteRatio: 0.99, + NumberOfComments: 1634, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8154, + + Author: "kmiller0112", + AuthorID: "t2_30a5ktgt", + + IsSelfPost: true, + Stickied: true, + }, + { + ID: "hyhquk", + FullID: "t3_hyhquk", + Created: &Timestamp{time.Date(2020, 7, 27, 0, 5, 10, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/test/comments/hyhquk/veggies/", + URL: "https://i.imgur.com/LrN2mPw.jpg", + + Title: "Veggies", + + Score: 4, + UpvoteRatio: 1, + NumberOfComments: 0, + + SubredditName: "test", + SubredditNamePrefixed: "r/test", + SubredditID: "t5_2qh23", + SubredditSubscribers: 8154, + + Author: "MuckleMcDuckle", + AuthorID: "t2_6fqntbwq", + }, +} + +var expectedSubreddit = &Subreddit{ + ID: "2rc7j", + FullID: "t5_2rc7j", + Created: &Timestamp{time.Date(2009, 11, 11, 0, 54, 28, 0, time.UTC)}, + + URL: "/r/golang/", + Name: "golang", + NamePrefixed: "r/golang", + Title: "The Go Programming Language", + Description: "Ask questions and post articles about the Go programming language and related tools, events etc.", + Type: "public", + + Subscribers: 116532, + ActiveUserCount: Int(386), + NSFW: false, + UserIsMod: false, + Subscribed: true, +} + +var expectedSubreddits = []*Subreddit{ + { + ID: "2qs0k", + FullID: "t5_2qs0k", + Created: &Timestamp{time.Date(2009, 1, 25, 2, 25, 57, 0, time.UTC)}, + + URL: "/r/Home/", + Name: "Home", + NamePrefixed: "r/Home", + Title: "Home", + Type: "public", + + Subscribers: 15336, + NSFW: false, + UserIsMod: false, + Subscribed: true, + Favorite: false, + }, + { + ID: "2qh1i", + FullID: "t5_2qh1i", + Created: &Timestamp{time.Date(2008, 1, 25, 3, 52, 15, 0, time.UTC)}, + + URL: "/r/AskReddit/", + Name: "AskReddit", + NamePrefixed: "r/AskReddit", + Title: "Ask Reddit...", + Description: "r/AskReddit is the place to ask and answer thought-provoking questions.", + Type: "public", + + Subscribers: 28449174, + NSFW: false, + UserIsMod: false, + Subscribed: true, + Favorite: true, + }, + { + ID: "2qh0u", + FullID: "t5_2qh0u", + Created: &Timestamp{time.Date(2008, 1, 25, 0, 31, 9, 0, time.UTC)}, + + URL: "/r/pics/", + Name: "pics", + NamePrefixed: "r/pics", + Title: "Reddit Pics", + Description: "A place for pictures and photographs.", + Type: "public", + + Subscribers: 24987753, + NSFW: false, + UserIsMod: false, + Subscribed: false, + Favorite: false, + }, +} + +var expectedSubredditNames = []string{ + "golang", + "golang_infosec", + "GolangJobOfferings", + "golanguage", + "golang2", + "GolangUnofficial", + "golang_jobs", +} + +var expectedSearchPosts = []*Post{ + { + ID: "hybow9", + FullID: "t3_hybow9", + Created: &Timestamp{time.Date(2020, 7, 26, 18, 14, 24, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/WatchPeopleDieInside/comments/hybow9/pregnancy_test/", + URL: "https://v.redd.it/ra4qnt8bt8d51", + + Title: "Pregnancy test", + + Score: 103829, + UpvoteRatio: 0.88, + NumberOfComments: 3748, + + SubredditName: "WatchPeopleDieInside", + SubredditNamePrefixed: "r/WatchPeopleDieInside", + SubredditID: "t5_3h4zq", + SubredditSubscribers: 2599948, + + Author: "chocolat_ice_cream", + AuthorID: "t2_3p32m02", + }, + { + ID: "hmwhd7", + FullID: "t3_hmwhd7", + Created: &Timestamp{time.Date(2020, 7, 7, 15, 19, 42, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/worldnews/comments/hmwhd7/brazilian_president_jair_bolsonaro_tests_positive/", + URL: "https://www.theguardian.com/world/2020/jul/07/jair-bolsonaro-coronavirus-positive-test-brazil-president", + + Title: "Brazilian president Jair Bolsonaro tests positive for coronavirus", + + Score: 149238, + UpvoteRatio: 0.94, + NumberOfComments: 7415, + + SubredditName: "worldnews", + SubredditNamePrefixed: "r/worldnews", + SubredditID: "t5_2qh13", + SubredditSubscribers: 24651441, + + Author: "Jeremy_Martin", + AuthorID: "t2_wgrkg", + }, +} + +var expectedRandomSubreddit = &Subreddit{ + FullID: "t5_2wi4l", + Created: &Timestamp{time.Date(2013, 3, 1, 4, 4, 18, 0, time.UTC)}, + + URL: "/r/GalaxyS8/", + Name: "GalaxyS8", + NamePrefixed: "r/GalaxyS8", + Title: "Samsung Galaxy S8", + Description: "The only place for news, discussion, photos, and everything else Samsung Galaxy S8.", + Type: "public", + + Subscribers: 52357, +} + +var expectedRelationships3 = []*Relationship{ + { + ID: "rel_id1", + Created: &Timestamp{time.Date(2020, 8, 11, 2, 35, 2, 0, time.UTC)}, + User: "testuser1", + UserID: "t2_user1", + }, + { + ID: "rel_id2", + Created: &Timestamp{time.Date(2020, 8, 11, 2, 35, 0, 0, time.UTC)}, + User: "testuser2", + UserID: "t2_user2", + }, +} + +var expectedBans = []*Ban{ + { + Relationship: &Relationship{ + ID: "rb_123", + Created: &Timestamp{time.Date(2020, 8, 11, 2, 35, 2, 0, time.UTC)}, + + User: "testuser1", + UserID: "t2_user1", + }, + + DaysLeft: Int(43), + Note: "Spam", + }, + { + Relationship: &Relationship{ + ID: "rb_456", + Created: &Timestamp{time.Date(2020, 8, 11, 2, 35, 0, 0, time.UTC)}, + + User: "testuser2", + UserID: "t2_user2", + }, + + DaysLeft: nil, + Note: "Spam", + }, +} + +var expectedModerators = []*Moderator{ + { + Relationship: &Relationship{ + ID: "rb_tmatb9", + User: "testuser1", + UserID: "t2_test1", + Created: &Timestamp{time.Date(2013, 7, 29, 20, 44, 27, 0, time.UTC)}, + }, + Permissions: []string{"all"}, + }, + { + Relationship: &Relationship{ + ID: "rb_5c9s4d", + User: "testuser2", + UserID: "t2_test2", + Created: &Timestamp{time.Date(2014, 3, 1, 18, 13, 53, 0, time.UTC)}, + }, + Permissions: []string{"all"}, + }, +} + +var expectedRules = []*SubredditRule{ + { + Kind: "link", + Name: "Read the Rules Before Posting", + ViolationReason: "Read the Rules Before Posting", + Description: "https://www.reddit.com/r/Fitness/wiki/rules", + Priority: 0, + Created: &Timestamp{time.Date(2019, 5, 22, 5, 32, 58, 0, time.UTC)}, + }, + { + Kind: "link", + Name: "Read the Wiki Before Posting", + ViolationReason: "Read the Wiki Before Posting", + Description: "https://thefitness.wiki", + Priority: 1, + Created: &Timestamp{time.Date(2019, 11, 9, 7, 56, 33, 0, time.UTC)}, + }, +} + +var expectedDayTraffic = []*SubredditTrafficStats{ + {&Timestamp{time.Date(2020, 9, 13, 0, 0, 0, 0, time.UTC)}, 0, 0, 0}, + {&Timestamp{time.Date(2020, 9, 12, 0, 0, 0, 0, time.UTC)}, 1, 12, 0}, + {&Timestamp{time.Date(2020, 9, 11, 0, 0, 0, 0, time.UTC)}, 5, 85, 0}, + {&Timestamp{time.Date(2020, 9, 10, 0, 0, 0, 0, time.UTC)}, 4, 20, 0}, + {&Timestamp{time.Date(2020, 9, 9, 0, 0, 0, 0, time.UTC)}, 2, 64, 0}, + {&Timestamp{time.Date(2020, 9, 8, 0, 0, 0, 0, time.UTC)}, 2, 95, 0}, + {&Timestamp{time.Date(2020, 9, 7, 0, 0, 0, 0, time.UTC)}, 3, 41, 0}, +} + +var expectedHourTraffic = []*SubredditTrafficStats{ + {&Timestamp{time.Date(2020, 9, 12, 20, 0, 0, 0, time.UTC)}, 1, 12, 0}, + {&Timestamp{time.Date(2020, 9, 11, 3, 0, 0, 0, time.UTC)}, 4, 57, 0}, + {&Timestamp{time.Date(2020, 9, 11, 2, 0, 0, 0, time.UTC)}, 4, 28, 0}, +} + +var expectedMonthTraffic = []*SubredditTrafficStats{ + {&Timestamp{time.Date(2020, 9, 1, 0, 0, 0, 0, time.UTC)}, 7, 481, 0}, + {&Timestamp{time.Date(2020, 8, 1, 0, 0, 0, 0, time.UTC)}, 5, 346, 0}, + {&Timestamp{time.Date(2020, 7, 1, 0, 0, 0, 0, time.UTC)}, 4, 264, 0}, +} + +var expectedStyleSheet = &SubredditStyleSheet{ + SubredditID: "t5_2rc7j", + Images: []*SubredditImage{ + { + Name: "gopher", + Link: "url(%%gopher%%)", + URL: "http://b.thumbs.redditmedia.com/q5Wb6hTPm2Bd6Of9_xMrTu4n5qgAljJNqtnbE3Tging.png", + }, + }, + StyleSheet: `.flair-gopher { + background: url(%%gopher%%) no-repeat; + border: 0; + padding: 0; + width: 16px; + height: 16px; +}`, +} + +var expectedSubredditSettings = &SubredditSettings{ + ID: "t5_test", + + Type: String("private"), + + Language: String("en"), + + Title: String("hello!"), + Description: String("description"), + Sidebar: String("sidebar"), + SubmissionText: String(""), + WelcomeMessage: String(""), + WelcomeMessageEnabled: Bool(false), + + AllowCrossposts: Bool(false), + AllowChatPosts: Bool(true), + AllowPollPosts: Bool(false), + AllowFreeFormReports: Bool(true), + AllowOriginalContent: Bool(false), + AllowImages: Bool(true), + AllowMultipleImagesPerPost: Bool(true), + + ExcludeSitewideBannedUsersContent: Bool(false), + + CrowdControlChalLevel: Int(2), + + AllOriginalContent: Bool(false), + + SuggestedCommentSort: nil, + + SubmitLinkPostLabel: String("submit a link!"), + SubmitTextPostLabel: String("submit a post!"), + + PostType: String("any"), + + SpamFilterStrengthLinkPosts: String("low"), + SpamFilterStrengthTextPosts: String("low"), + SpamFilterStrengthComments: String("low"), + + ShowContentThumbnails: Bool(false), + ExpandMediaPreviewsOnCommentsPages: Bool(true), + + CollapseDeletedComments: Bool(false), + MinutesToHideCommentScores: Int(0), + + SpoilersEnabled: Bool(true), + + HeaderMouseoverText: String("hello!"), + + MobileColour: String(""), + + HideAds: Bool(false), + NSFW: Bool(false), + + AllowDiscoveryInHighTrafficFeeds: Bool(true), + AllowDiscoveryByIndividualUsers: Bool(true), + + WikiMode: String("modonly"), + WikiMinimumAccountAge: Int(0), + WikiMinimumKarma: Int(0), +} + +var expectedSubredditPostRequirements = &SubredditPostRequirements{ + Guidelines: "test", + GuidelinesDisplayPolicy: "", + + TitleMinLength: 50, + TitleMaxLength: 200, + + BodyMinLength: 50, + BodyMaxLength: 2000, + + TitleBlacklistedStrings: []string{"no"}, + BodyBlacklistedStrings: []string{"no"}, + + TitleRequiredStrings: []string{"yes"}, + BodyRequiredStrings: []string{"yes"}, + + DomainBlacklist: []string{"example.com"}, + DomainWhitelist: []string{}, + + BodyRestrictionPolicy: "none", + LinkRestrictionPolicy: "none", + + GalleryMinItems: 2, + GalleryMaxItems: 20, + GalleryCaptionsRequirement: "none", + GalleryURLsRequirement: "none", + + LinkRepostAge: 2, + FlairRequired: false, + + TitleRegexes: []string{}, + BodyRegexes: []string{}, +} + +func TestSubredditService_HotPosts(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/posts.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/hot", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.Subreddit.HotPosts(ctx, "test", nil) + require.NoError(t, err) + require.Equal(t, expectedPosts, posts) + require.Equal(t, "t3_hyhquk", resp.After) +} + +func TestSubredditService_NewPosts(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/posts.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/new", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.Subreddit.NewPosts(ctx, "test", nil) + require.NoError(t, err) + require.Equal(t, expectedPosts, posts) + require.Equal(t, "t3_hyhquk", resp.After) +} + +func TestSubredditService_RisingPosts(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/posts.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/rising", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.Subreddit.RisingPosts(ctx, "test", nil) + require.NoError(t, err) + require.Equal(t, expectedPosts, posts) + require.Equal(t, "t3_hyhquk", resp.After) +} + +func TestSubredditService_ControversialPosts(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/posts.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/controversial", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.Subreddit.ControversialPosts(ctx, "test", nil) + require.NoError(t, err) + require.Equal(t, expectedPosts, posts) + require.Equal(t, "t3_hyhquk", resp.After) +} + +func TestSubredditService_TopPosts(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/posts.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/top", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.Subreddit.TopPosts(ctx, "test", nil) + require.NoError(t, err) + require.Equal(t, expectedPosts, posts) + require.Equal(t, "t3_hyhquk", resp.After) +} + +func TestSubredditService_Get(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/about.json") + require.NoError(t, err) + + mux.HandleFunc("/r/golang/about", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + _, _, err = client.Subreddit.Get(ctx, "") + require.EqualError(t, err, "name: cannot be empty") + + subreddit, _, err := client.Subreddit.Get(ctx, "golang") + require.NoError(t, err) + require.Equal(t, expectedSubreddit, subreddit) +} + +func TestSubredditService_Popular(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/list.json") + require.NoError(t, err) + + mux.HandleFunc("/subreddits/popular", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + subreddits, resp, err := client.Subreddit.Popular(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedSubreddits, subreddits) + require.Equal(t, "t5_2qh0u", resp.After) +} + +func TestSubredditService_New(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/list.json") + require.NoError(t, err) + + mux.HandleFunc("/subreddits/new", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + subreddits, resp, err := client.Subreddit.New(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedSubreddits, subreddits) + require.Equal(t, "t5_2qh0u", resp.After) +} + +func TestSubredditService_Gold(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/list.json") + require.NoError(t, err) + + mux.HandleFunc("/subreddits/gold", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + subreddits, resp, err := client.Subreddit.Gold(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedSubreddits, subreddits) + require.Equal(t, "t5_2qh0u", resp.After) +} + +func TestSubredditService_Default(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/list.json") + require.NoError(t, err) + + mux.HandleFunc("/subreddits/default", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + subreddits, resp, err := client.Subreddit.Default(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedSubreddits, subreddits) + require.Equal(t, "t5_2qh0u", resp.After) +} + +func TestSubredditService_Subscribed(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/list.json") + require.NoError(t, err) + + mux.HandleFunc("/subreddits/mine/subscriber", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + subreddits, resp, err := client.Subreddit.Subscribed(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedSubreddits, subreddits) + require.Equal(t, "t5_2qh0u", resp.After) +} + +func TestSubredditService_Approved(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/list.json") + require.NoError(t, err) + + mux.HandleFunc("/subreddits/mine/contributor", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + subreddits, resp, err := client.Subreddit.Approved(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedSubreddits, subreddits) + require.Equal(t, "t5_2qh0u", resp.After) +} + +func TestSubredditService_Moderated(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/list.json") + require.NoError(t, err) + + mux.HandleFunc("/subreddits/mine/moderator", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + subreddits, resp, err := client.Subreddit.Moderated(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedSubreddits, subreddits) + require.Equal(t, "t5_2qh0u", resp.After) +} + +func TestSubredditService_GetSticky1(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/post.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/about/sticky", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, "1", r.Form.Get("num")) + + fmt.Fprint(w, blob) + }) + + postAndComments, _, err := client.Subreddit.GetSticky1(ctx, "test") + require.NoError(t, err) + require.Equal(t, expectedPostAndComments, postAndComments) +} + +func TestSubredditService_GetSticky2(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/post/post.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/about/sticky", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, "2", r.Form.Get("num")) + + fmt.Fprint(w, blob) + }) + + postAndComments, _, err := client.Subreddit.GetSticky2(ctx, "test") + require.NoError(t, err) + require.Equal(t, expectedPostAndComments, postAndComments) +} + +func TestSubredditService_Subscribe(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/subscribe", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("action", "sub") + form.Set("sr_name", "test,golang,nba") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.Subscribe(ctx, "test", "golang", "nba") + require.NoError(t, err) +} + +func TestSubredditService_SubscribeByID(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/subscribe", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("action", "sub") + form.Set("sr", "t5_test1,t5_test2,t5_test3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.SubscribeByID(ctx, "t5_test1", "t5_test2", "t5_test3") + require.NoError(t, err) +} + +func TestSubredditService_Unsubscribe(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/subscribe", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("action", "unsub") + form.Set("sr_name", "test,golang,nba") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.Unsubscribe(ctx, "test", "golang", "nba") + require.NoError(t, err) +} + +func TestSubredditService_UnsubscribeByID(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/subscribe", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("action", "unsub") + form.Set("sr", "t5_test1,t5_test2,t5_test3") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.UnsubscribeByID(ctx, "t5_test1", "t5_test2", "t5_test3") + require.NoError(t, err) +} + +func TestSubredditService_Favorite(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/favorite", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("sr_name", "testsubreddit") + form.Set("make_favorite", "true") + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.Favorite(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestSubredditService_Unfavorite(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/favorite", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("sr_name", "testsubreddit") + form.Set("make_favorite", "false") + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.Unfavorite(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestSubredditService_Search(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/list.json") + require.NoError(t, err) + + mux.HandleFunc("/subreddits/search", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("q", "golang") + form.Set("limit", "10") + form.Set("sort", "activity") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + subreddits, resp, err := client.Subreddit.Search(ctx, "golang", &ListSubredditOptions{ + ListOptions: ListOptions{ + Limit: 10, + }, + Sort: "activity", + }) + require.NoError(t, err) + require.Equal(t, expectedSubreddits, subreddits) + require.Equal(t, "t5_2qh0u", resp.After) +} + +func TestSubredditService_SearchNames(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/search-names.json") + require.NoError(t, err) + + mux.HandleFunc("/api/search_reddit_names", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("query", "golang") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + names, _, err := client.Subreddit.SearchNames(ctx, "golang") + require.NoError(t, err) + require.Equal(t, expectedSubredditNames, names) +} + +func TestSubredditService_SearchPosts(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/search-posts.json") + require.NoError(t, err) + + mux.HandleFunc("/r/all/search", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("q", "test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.Subreddit.SearchPosts(ctx, "test", "", nil) + require.NoError(t, err) + require.Equal(t, expectedSearchPosts, posts) + require.Equal(t, "t3_hmwhd7", resp.After) +} + +func TestSubredditService_SearchPosts_InSubreddit(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/search-posts.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/search", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("q", "test") + form.Set("restrict_sr", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.Subreddit.SearchPosts(ctx, "test", "test", nil) + require.NoError(t, err) + require.Equal(t, expectedSearchPosts, posts) + require.Equal(t, "t3_hmwhd7", resp.After) +} + +func TestSubredditService_SearchPosts_InSubreddits(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/search-posts.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test+golang+nba/search", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("q", "test") + form.Set("restrict_sr", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.Subreddit.SearchPosts(ctx, "test", "test+golang+nba", nil) + require.NoError(t, err) + require.Equal(t, expectedSearchPosts, posts) + require.Equal(t, "t3_hmwhd7", resp.After) +} + +func TestSubredditService_Random(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/random.json") + require.NoError(t, err) + + mux.HandleFunc("/r/random", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, "true", r.Form.Get("sr_detail")) + require.Equal(t, "1", r.Form.Get("limit")) + + fmt.Fprint(w, blob) + }) + + subreddit, _, err := client.Subreddit.Random(ctx) + require.NoError(t, err) + require.Equal(t, expectedRandomSubreddit, subreddit) +} + +func TestSubredditService_RandomNSFW(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/random.json") + require.NoError(t, err) + + mux.HandleFunc("/r/randnsfw", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, "true", r.Form.Get("sr_detail")) + require.Equal(t, "1", r.Form.Get("limit")) + + fmt.Fprint(w, blob) + }) + + subreddit, _, err := client.Subreddit.RandomNSFW(ctx) + require.NoError(t, err) + require.Equal(t, expectedRandomSubreddit, subreddit) +} + +func TestSubredditService_SubmissionText(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/test/api/submit_text", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, `{ + "submit_text": "this is a test", + "submit_text_html": "" + }`) + }) + + text, _, err := client.Subreddit.SubmissionText(ctx, "test") + require.NoError(t, err) + require.Equal(t, "this is a test", text) +} + +func TestSubredditService_Banned(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/banned-users.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/about/banned", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("after", "testafter") + form.Set("limit", "10") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + bans, _, err := client.Subreddit.Banned(ctx, "test", &ListOptions{After: "testafter", Limit: 10}) + require.NoError(t, err) + require.Equal(t, expectedBans, bans) +} + +func TestSubredditService_Muted(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/relationships.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/about/muted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("before", "testbefore") + form.Set("limit", "50") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + mutes, _, err := client.Subreddit.Muted(ctx, "test", &ListOptions{Before: "testbefore", Limit: 50}) + require.NoError(t, err) + require.Equal(t, expectedRelationships3, mutes) +} + +func TestSubredditService_WikiBanned(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/banned-users.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/about/wikibanned", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("after", "testafter") + form.Set("limit", "15") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + bans, _, err := client.Subreddit.WikiBanned(ctx, "test", &ListOptions{After: "testafter", Limit: 15}) + require.NoError(t, err) + require.Equal(t, expectedBans, bans) +} + +func TestSubredditService_Contributors(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/relationships.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/about/contributors", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "5") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + contributors, _, err := client.Subreddit.Contributors(ctx, "test", &ListOptions{Limit: 5}) + require.NoError(t, err) + require.Equal(t, expectedRelationships3, contributors) +} + +func TestSubredditService_WikiContributors(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/relationships.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/about/wikicontributors", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "99") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + contributors, _, err := client.Subreddit.WikiContributors(ctx, "test", &ListOptions{Limit: 99}) + require.NoError(t, err) + require.Equal(t, expectedRelationships3, contributors) +} + +func TestSubredditService_Moderators(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/moderators.json") + require.NoError(t, err) + + mux.HandleFunc("/r/test/about/moderators", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + moderators, _, err := client.Subreddit.Moderators(ctx, "test") + require.NoError(t, err) + require.Equal(t, expectedModerators, moderators) +} + +func TestSubredditService_Rules(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/rules.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/rules", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + rules, _, err := client.Subreddit.Rules(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedRules, rules) +} + +func TestSubredditService_CreateRule(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/add_subreddit_rule", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("kind", "all") + form.Set("short_name", "testname") + form.Set("violation_reason", "testreason") + form.Set("description", "testdescription") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{ + Kind: "all", + Name: "testname", + ViolationReason: "testreason", + Description: "testdescription", + }) + require.NoError(t, err) +} + +func TestSubredditService_CreateRule_Error(t *testing.T) { + client, _ := setup(t) + + _, err := client.Subreddit.CreateRule(ctx, "testsubreddit", nil) + require.EqualError(t, err, "*SubredditRuleCreateRequest: cannot be nil") + + _, err = client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{Kind: "invalid"}) + require.EqualError(t, err, "(*SubredditRuleCreateRequest).Kind: must be one of: comment, link, all") + + _, err = client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{Kind: "all", Name: ""}) + require.EqualError(t, err, "(*SubredditRuleCreateRequest).Name: must be between 1-100 characters") + + _, err = client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{ + Kind: "all", + Name: "testname", + ViolationReason: strings.Repeat("x", 101), + }) + require.EqualError(t, err, "(*SubredditRuleCreateRequest).ViolationReason: cannot be longer than 100 characters") + + _, err = client.Subreddit.CreateRule(ctx, "testsubreddit", &SubredditRuleCreateRequest{ + Kind: "all", + Name: "testname", + Description: strings.Repeat("x", 501), + }) + require.EqualError(t, err, "(*SubredditRuleCreateRequest).Description: cannot be longer than 500 characters") +} + +func TestSubredditService_Traffic(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/traffic.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/traffic", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + dayTraffic, hourTraffic, monthTraffic, _, err := client.Subreddit.Traffic(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedDayTraffic, dayTraffic) + require.Equal(t, expectedHourTraffic, hourTraffic) + require.Equal(t, expectedMonthTraffic, monthTraffic) +} + +func TestSubredditService_StyleSheet(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/stylesheet.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/stylesheet", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + styleSheet, _, err := client.Subreddit.StyleSheet(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedStyleSheet, styleSheet) +} + +func TestSubredditService_StyleSheetRaw(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/stylesheet", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, "* { box-sizing: border-box; }") + }) + + styleSheet, _, err := client.Subreddit.StyleSheetRaw(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, "* { box-sizing: border-box; }", styleSheet) +} + +func TestSubredditService_UpdateStyleSheet(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/subreddit_stylesheet", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("op", "save") + form.Set("stylesheet_contents", "* { box-sizing: border-box; }") + form.Set("reason", "testreason") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.UpdateStyleSheet(ctx, "testsubreddit", "* { box-sizing: border-box; }", "testreason") + require.NoError(t, err) +} + +func TestSubredditService_RemoveImage(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/delete_sr_img", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("img_name", "testimage") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.RemoveImage(ctx, "testsubreddit", "testimage") + require.NoError(t, err) +} + +func TestSubredditService_RemoveHeader(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/delete_sr_header", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.RemoveHeader(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestSubredditService_RemoveMobileHeader(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/delete_sr_banner", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.RemoveMobileHeader(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestSubredditService_RemoveMobileIcon(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/delete_sr_icon", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.RemoveMobileIcon(ctx, "testsubreddit") + require.NoError(t, err) +} + +func TestSubredditService_UploadImage(t *testing.T) { + client, mux := setup(t) + + imageFile, err := ioutil.TempFile("/tmp", "emoji*.png") + require.NoError(t, err) + defer func() { + imageFile.Close() + os.Remove(imageFile.Name()) + }() + + _, err = imageFile.WriteString("this is a test") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/upload_sr_img", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + _, file, err := r.FormFile("file") + require.NoError(t, err) + + rdr, err := file.Open() + require.NoError(t, err) + + buf := new(bytes.Buffer) + _, err = io.Copy(buf, rdr) + require.NoError(t, err) + require.Equal(t, "this is a test", buf.String()) + + form := url.Values{} + form.Set("upload_type", "img") + form.Set("name", "testname") + form.Set("img_type", "png") + + err = r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, `{ + "img_src": "https://example.com/test.png" + }`) + }) + + link, _, err := client.Subreddit.UploadImage(ctx, "testsubreddit", imageFile.Name(), "testname") + require.NoError(t, err) + require.Equal(t, "https://example.com/test.png", link) +} + +func TestSubredditService_UploadHeader(t *testing.T) { + client, mux := setup(t) + + imageFile, err := ioutil.TempFile("/tmp", "emoji*.png") + require.NoError(t, err) + defer func() { + imageFile.Close() + os.Remove(imageFile.Name()) + }() + + _, err = imageFile.WriteString("this is a test") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/upload_sr_img", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + _, file, err := r.FormFile("file") + require.NoError(t, err) + + rdr, err := file.Open() + require.NoError(t, err) + + buf := new(bytes.Buffer) + _, err = io.Copy(buf, rdr) + require.NoError(t, err) + require.Equal(t, "this is a test", buf.String()) + + form := url.Values{} + form.Set("upload_type", "header") + form.Set("name", "testname") + form.Set("img_type", "png") + + err = r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, `{ + "img_src": "https://example.com/test.png" + }`) + }) + + link, _, err := client.Subreddit.UploadHeader(ctx, "testsubreddit", imageFile.Name(), "testname") + require.NoError(t, err) + require.Equal(t, "https://example.com/test.png", link) +} + +func TestSubredditService_UploadMobileHeader(t *testing.T) { + client, mux := setup(t) + + imageFile, err := ioutil.TempFile("/tmp", "emoji*.png") + require.NoError(t, err) + defer func() { + imageFile.Close() + os.Remove(imageFile.Name()) + }() + + _, err = imageFile.WriteString("this is a test") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/upload_sr_img", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + _, file, err := r.FormFile("file") + require.NoError(t, err) + + rdr, err := file.Open() + require.NoError(t, err) + + buf := new(bytes.Buffer) + _, err = io.Copy(buf, rdr) + require.NoError(t, err) + require.Equal(t, "this is a test", buf.String()) + + form := url.Values{} + form.Set("upload_type", "banner") + form.Set("name", "testname") + form.Set("img_type", "png") + + err = r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, `{ + "img_src": "https://example.com/test.png" + }`) + }) + + link, _, err := client.Subreddit.UploadMobileHeader(ctx, "testsubreddit", imageFile.Name(), "testname") + require.NoError(t, err) + require.Equal(t, "https://example.com/test.png", link) +} + +func TestSubredditService_UploadMobileIcon(t *testing.T) { + client, mux := setup(t) + + imageFile, err := ioutil.TempFile("/tmp", "emoji*.jpg") + require.NoError(t, err) + defer func() { + imageFile.Close() + os.Remove(imageFile.Name()) + }() + + _, err = imageFile.WriteString("this is a test") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/upload_sr_img", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + _, file, err := r.FormFile("file") + require.NoError(t, err) + + rdr, err := file.Open() + require.NoError(t, err) + + buf := new(bytes.Buffer) + _, err = io.Copy(buf, rdr) + require.NoError(t, err) + require.Equal(t, "this is a test", buf.String()) + + form := url.Values{} + form.Set("upload_type", "icon") + form.Set("name", "testname") + form.Set("img_type", "jpg") + + err = r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, `{ + "img_src": "https://example.com/test.jpg" + }`) + }) + + link, _, err := client.Subreddit.UploadMobileIcon(ctx, "testsubreddit", imageFile.Name(), "testname") + require.NoError(t, err) + require.Equal(t, "https://example.com/test.jpg", link) +} + +func TestSubredditService_UploadImage_Error(t *testing.T) { + client, mux := setup(t) + + imageFile, err := ioutil.TempFile("/tmp", "emoji*.jpg") + require.NoError(t, err) + defer func() { + imageFile.Close() + os.Remove(imageFile.Name()) + }() + + mux.HandleFunc("/r/testsubreddit/api/upload_sr_img", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + fmt.Fprint(w, `{ + "errors_values": [ + "error one", + "error two" + ] + }`) + }) + + _, _, err = client.Subreddit.UploadImage(ctx, "testsubreddit", "does-not-exist.jpg", "testname") + require.EqualError(t, err, "open does-not-exist.jpg: no such file or directory") + + _, _, err = client.Subreddit.UploadImage(ctx, "testsubreddit", imageFile.Name(), "testname") + require.EqualError(t, err, "could not upload image: error one; error two") +} + +func TestSubredditService_Create(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/site_admin", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("name", "testsubreddit") + form.Set("type", "private") + form.Set("lang", "en") + form.Set("title", "hello!") + form.Set("public_description", "description") + form.Set("description", "sidebar") + form.Set("submit_text", "") + form.Set("welcome_message_text", "") + form.Set("welcome_message_enabled", "false") + form.Set("allow_post_crossposts", "false") + form.Set("allow_chat_post_creation", "true") + form.Set("allow_polls", "false") + form.Set("free_form_reports", "true") + form.Set("original_content_tag_enabled", "false") + form.Set("allow_images", "true") + form.Set("allow_galleries", "true") + form.Set("exclude_banned_modqueue", "false") + form.Set("crowd_control_chat_level", "2") + form.Set("all_original_content", "false") + form.Set("submit_link_label", "submit a link!") + form.Set("submit_text_label", "submit a post!") + form.Set("link_type", "any") + form.Set("spam_links", "low") + form.Set("spam_selfposts", "low") + form.Set("spam_comments", "low") + form.Set("show_media", "false") + form.Set("show_media_preview", "true") + form.Set("collapse_deleted_comments", "false") + form.Set("comment_score_hide_mins", "0") + form.Set("spoilers_enabled", "true") + form.Set("header-title", "hello!") + form.Set("key_color", "") + form.Set("hide_ads", "false") + form.Set("over_18", "false") + form.Set("allow_top", "true") + form.Set("allow_discovery", "true") + form.Set("wikimode", "modonly") + form.Set("wiki_edit_age", "0") + form.Set("wiki_edit_karma", "0") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.Create(ctx, "testsubreddit", nil) + require.EqualError(t, err, "*SubredditSettings: cannot be nil") + + _, err = client.Subreddit.Create(ctx, "testsubreddit", expectedSubredditSettings) + require.NoError(t, err) +} + +func TestSubredditService_Edit(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/site_admin", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("api_type", "json") + form.Set("sr", "t5_test") + form.Set("type", "private") + form.Set("lang", "en") + form.Set("title", "hello!") + form.Set("public_description", "description") + form.Set("description", "sidebar") + form.Set("submit_text", "") + form.Set("welcome_message_text", "") + form.Set("welcome_message_enabled", "false") + form.Set("allow_post_crossposts", "false") + form.Set("allow_chat_post_creation", "true") + form.Set("allow_polls", "false") + form.Set("free_form_reports", "true") + form.Set("original_content_tag_enabled", "false") + form.Set("allow_images", "true") + form.Set("allow_galleries", "true") + form.Set("exclude_banned_modqueue", "false") + form.Set("crowd_control_chat_level", "2") + form.Set("all_original_content", "false") + form.Set("submit_link_label", "submit a link!") + form.Set("submit_text_label", "submit a post!") + form.Set("link_type", "any") + form.Set("spam_links", "low") + form.Set("spam_selfposts", "low") + form.Set("spam_comments", "low") + form.Set("show_media", "false") + form.Set("show_media_preview", "true") + form.Set("collapse_deleted_comments", "false") + form.Set("comment_score_hide_mins", "0") + form.Set("spoilers_enabled", "true") + form.Set("header-title", "hello!") + form.Set("key_color", "") + form.Set("hide_ads", "false") + form.Set("over_18", "false") + form.Set("allow_top", "true") + form.Set("allow_discovery", "true") + form.Set("wikimode", "modonly") + form.Set("wiki_edit_age", "0") + form.Set("wiki_edit_karma", "0") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Subreddit.Edit(ctx, "t5_test", nil) + require.EqualError(t, err, "*SubredditSettings: cannot be nil") + + _, err = client.Subreddit.Edit(ctx, "t5_test", expectedSubredditSettings) + require.NoError(t, err) +} + +func TestSubredditService_GetSettings(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/settings.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/about/edit", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + subredditSettings, _, err := client.Subreddit.GetSettings(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedSubredditSettings, subredditSettings) +} + +func TestSubredditService_PostRequirements(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/subreddit/post-requirements.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/testsubreddit/post_requirements", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + postRequirements, _, err := client.Subreddit.PostRequirements(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, expectedSubredditPostRequirements, postRequirements) +} diff --git a/v2/reddit/things.go b/v2/reddit/things.go new file mode 100644 index 0000000..1a591fd --- /dev/null +++ b/v2/reddit/things.go @@ -0,0 +1,791 @@ +package reddit + +import ( + "encoding/json" + "fmt" +) + +const ( + kindComment = "t1" + kindUser = "t2" + kindPost = "t3" + kindMessage = "t4" + kindSubreddit = "t5" + kindTrophy = "t6" + kindListing = "Listing" + kindSubredditSettings = "subreddit_settings" + kindKarmaList = "KarmaList" + kindTrophyList = "TrophyList" + kindUserList = "UserList" + kindMore = "more" + kindLiveThread = "LiveUpdateEvent" + kindLiveThreadUpdate = "LiveUpdate" + kindModAction = "modaction" + kindMulti = "LabeledMulti" + kindMultiDescription = "LabeledMultiDescription" + kindWikiPage = "wikipage" + kindWikiPageListing = "wikipagelisting" + kindWikiPageSettings = "wikipagesettings" + kindStyleSheet = "stylesheet" +) + +type anchor interface { + After() string +} + +// thing is an entity on Reddit. +// Its kind reprsents what it is and what is stored in the Data field. +// e.g. t1 = comment, t2 = user, t3 = post, etc. +type thing struct { + Kind string `json:"kind"` + Data interface{} `json:"data"` +} + +func (t *thing) After() string { + if t == nil { + return "" + } + a, ok := t.Data.(anchor) + if !ok { + return "" + } + return a.After() +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (t *thing) UnmarshalJSON(b []byte) error { + root := new(struct { + Kind string `json:"kind"` + Data json.RawMessage `json:"data"` + }) + + err := json.Unmarshal(b, root) + if err != nil { + return err + } + + t.Kind = root.Kind + var v interface{} + + switch t.Kind { + case kindListing: + v = new(listing) + case kindComment: + v = new(Comment) + case kindMore: + v = new(More) + case kindUser: + v = new(User) + case kindPost: + v = new(Post) + case kindSubreddit: + v = new(Subreddit) + case kindSubredditSettings: + v = new(SubredditSettings) + case kindLiveThread: + v = new(LiveThread) + case kindLiveThreadUpdate: + v = new(LiveThreadUpdate) + case kindModAction: + v = new(ModAction) + case kindMulti: + v = new(Multi) + case kindMultiDescription: + v = new(rootMultiDescription) + case kindTrophy: + v = new(Trophy) + case kindTrophyList: + v = new(trophyList) + case kindKarmaList: + v = new([]*SubredditKarma) + case kindWikiPage: + v = new(WikiPage) + case kindWikiPageListing: + v = new([]string) + case kindWikiPageSettings: + v = new(WikiPageSettings) + case kindStyleSheet: + v = new(SubredditStyleSheet) + default: + return fmt.Errorf("unrecognized kind: %q", t.Kind) + } + + err = json.Unmarshal(root.Data, v) + if err != nil { + return err + } + + t.Data = v + return nil +} + +func (t *thing) Listing() (v *listing, ok bool) { + v, ok = t.Data.(*listing) + return +} + +func (t *thing) Comment() (v *Comment, ok bool) { + v, ok = t.Data.(*Comment) + return +} + +func (t *thing) More() (v *More, ok bool) { + v, ok = t.Data.(*More) + return +} + +func (t *thing) User() (v *User, ok bool) { + v, ok = t.Data.(*User) + return +} + +func (t *thing) Post() (v *Post, ok bool) { + v, ok = t.Data.(*Post) + return +} + +func (t *thing) Subreddit() (v *Subreddit, ok bool) { + v, ok = t.Data.(*Subreddit) + return +} + +func (t *thing) SubredditSettings() (v *SubredditSettings, ok bool) { + v, ok = t.Data.(*SubredditSettings) + return +} + +func (t *thing) LiveThread() (v *LiveThread, ok bool) { + v, ok = t.Data.(*LiveThread) + return +} + +func (t *thing) LiveThreadUpdate() (v *LiveThreadUpdate, ok bool) { + v, ok = t.Data.(*LiveThreadUpdate) + return +} + +func (t *thing) ModAction() (v *ModAction, ok bool) { + v, ok = t.Data.(*ModAction) + return +} + +func (t *thing) Multi() (v *Multi, ok bool) { + v, ok = t.Data.(*Multi) + return +} + +func (t *thing) MultiDescription() (s string, ok bool) { + v, ok := t.Data.(*rootMultiDescription) + if ok { + s = v.Body + } + return +} + +func (t *thing) Trophy() (v *Trophy, ok bool) { + v, ok = t.Data.(*Trophy) + return +} + +func (t *thing) TrophyList() ([]*Trophy, bool) { + v, ok := t.Data.(*trophyList) + if !ok { + return nil, ok + } + return *v, ok +} + +func (t *thing) Karma() ([]*SubredditKarma, bool) { + v, ok := t.Data.(*[]*SubredditKarma) + if !ok { + return nil, ok + } + return *v, ok +} + +func (t *thing) WikiPage() (v *WikiPage, ok bool) { + v, ok = t.Data.(*WikiPage) + return +} + +func (t *thing) WikiPages() ([]string, bool) { + v, ok := t.Data.(*[]string) + if !ok { + return nil, ok + } + return *v, ok +} + +func (t *thing) WikiPageSettings() (v *WikiPageSettings, ok bool) { + v, ok = t.Data.(*WikiPageSettings) + return +} + +func (t *thing) StyleSheet() (v *SubredditStyleSheet, ok bool) { + v, ok = t.Data.(*SubredditStyleSheet) + return +} + +// listing is a list of things coming from the Reddit API. +// It also contains the after anchor useful to get the next results via subsequent requests. +type listing struct { + things things + after string +} + +func (l *listing) After() string { + return l.after +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (l *listing) UnmarshalJSON(b []byte) error { + root := new(struct { + Things things `json:"children"` + After string `json:"after"` + }) + + err := json.Unmarshal(b, root) + if err != nil { + return err + } + + l.things = root.Things + l.after = root.After + + return nil +} + +func (l *listing) Comments() []*Comment { + if l == nil { + return nil + } + return l.things.Comments +} + +func (l *listing) Mores() []*More { + if l == nil { + return nil + } + return l.things.Mores +} + +func (l *listing) Users() []*User { + if l == nil { + return nil + } + return l.things.Users +} + +func (l *listing) Posts() []*Post { + if l == nil { + return nil + } + return l.things.Posts +} + +func (l *listing) Subreddits() []*Subreddit { + if l == nil { + return nil + } + return l.things.Subreddits +} + +func (l *listing) ModActions() []*ModAction { + if l == nil { + return nil + } + return l.things.ModActions +} + +func (l *listing) Multis() []*Multi { + if l == nil { + return nil + } + return l.things.Multis +} + +func (l *listing) LiveThreads() []*LiveThread { + if l == nil { + return nil + } + return l.things.LiveThreads +} + +func (l *listing) LiveThreadUpdates() []*LiveThreadUpdate { + if l == nil { + return nil + } + return l.things.LiveThreadUpdates +} + +type things struct { + Comments []*Comment + Mores []*More + Users []*User + Posts []*Post + Subreddits []*Subreddit + ModActions []*ModAction + Multis []*Multi + LiveThreads []*LiveThread + LiveThreadUpdates []*LiveThreadUpdate +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (t *things) UnmarshalJSON(b []byte) error { + var things []thing + if err := json.Unmarshal(b, &things); err != nil { + return err + } + + t.add(things...) + return nil +} + +func (t *things) add(things ...thing) { + for _, thing := range things { + switch v := thing.Data.(type) { + case *Comment: + t.Comments = append(t.Comments, v) + case *More: + t.Mores = append(t.Mores, v) + case *User: + t.Users = append(t.Users, v) + case *Post: + t.Posts = append(t.Posts, v) + case *Subreddit: + t.Subreddits = append(t.Subreddits, v) + case *ModAction: + t.ModActions = append(t.ModActions, v) + case *Multi: + t.Multis = append(t.Multis, v) + case *LiveThread: + t.LiveThreads = append(t.LiveThreads, v) + case *LiveThreadUpdate: + t.LiveThreadUpdates = append(t.LiveThreadUpdates, v) + } + } +} + +type trophyList []*Trophy + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (l *trophyList) UnmarshalJSON(b []byte) error { + root := new(struct { + Trophies []thing `json:"trophies"` + }) + + err := json.Unmarshal(b, root) + if err != nil { + return err + } + + *l = make(trophyList, 0, len(root.Trophies)) + for _, thing := range root.Trophies { + if trophy, ok := thing.Trophy(); ok { + *l = append(*l, trophy) + } + } + + return nil +} + +// Comment is a comment posted by a user. +type Comment struct { + ID string `json:"id,omitempty"` + FullID string `json:"name,omitempty"` + Created *Timestamp `json:"created_utc,omitempty"` + Edited *Timestamp `json:"edited,omitempty"` + + ParentID string `json:"parent_id,omitempty"` + Permalink string `json:"permalink,omitempty"` + + Body string `json:"body,omitempty"` + Author string `json:"author,omitempty"` + AuthorID string `json:"author_fullname,omitempty"` + AuthorFlairText string `json:"author_flair_text,omitempty"` + AuthorFlairID string `json:"author_flair_template_id,omitempty"` + + SubredditName string `json:"subreddit,omitempty"` + SubredditNamePrefixed string `json:"subreddit_name_prefixed,omitempty"` + SubredditID string `json:"subreddit_id,omitempty"` + + // Indicates if you've upvote/downvoted (true/false). + // If neither, it will be nil. + Likes *bool `json:"likes"` + + Score int `json:"score"` + Controversiality int `json:"controversiality"` + + PostID string `json:"link_id,omitempty"` + // This doesn't appear consistently. + PostTitle string `json:"link_title,omitempty"` + // This doesn't appear consistently. + PostPermalink string `json:"link_permalink,omitempty"` + // This doesn't appear consistently. + PostAuthor string `json:"link_author,omitempty"` + // This doesn't appear consistently. + PostNumComments *int `json:"num_comments,omitempty"` + + IsSubmitter bool `json:"is_submitter"` + ScoreHidden bool `json:"score_hidden"` + Saved bool `json:"saved"` + Stickied bool `json:"stickied"` + Locked bool `json:"locked"` + CanGild bool `json:"can_gild"` + NSFW bool `json:"over_18"` + + Distinguished string `json:"distinguished"` + Depth int `json:"depth"` + BodyHtml string `json:"body_html"` + + Replies Replies `json:"replies"` +} + +// HasMore determines whether the comment has more replies to load in its reply tree. +func (c *Comment) HasMore() bool { + return c.Replies.More != nil && len(c.Replies.More.Children) > 0 +} + +// Denotes whether the comment was written by a moderator +func (c *Comment) IsModerator() bool { + return c.Distinguished == "moderator" +} + +// addCommentToReplies traverses the comment tree to find the one +// that the 2nd comment is replying to. It then adds it to its replies. +func (c *Comment) addCommentToReplies(comment *Comment) { + if c.FullID == comment.ParentID { + c.Replies.Comments = append(c.Replies.Comments, comment) + return + } + + for _, reply := range c.Replies.Comments { + reply.addCommentToReplies(comment) + } +} + +func (c *Comment) addMoreToReplies(more *More) { + if c.FullID == more.ParentID { + c.Replies.More = more + return + } + + for _, reply := range c.Replies.Comments { + reply.addMoreToReplies(more) + } +} + +// Replies holds replies to a comment. +// It contains both comments and "more" comments, which are entrypoints to other +// comments that were left out. +type Replies struct { + Comments []*Comment `json:"comments,omitempty"` + More *More `json:"-"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (r *Replies) UnmarshalJSON(data []byte) error { + // if a comment has no replies, its "replies" field is set to "" + if string(data) == `""` { + r = nil + return nil + } + + root := new(thing) + err := json.Unmarshal(data, root) + if err != nil { + return err + } + + listing, _ := root.Listing() + + r.Comments = listing.Comments() + if len(listing.Mores()) > 0 { + r.More = listing.Mores()[0] + } + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +func (r *Replies) MarshalJSON() ([]byte, error) { + if r == nil || len(r.Comments) == 0 { + return []byte(`null`), nil + } + return json.Marshal(r.Comments) +} + +// More holds information used to retrieve additional comments omitted from a base comment tree. +type More struct { + ID string `json:"id"` + FullID string `json:"name"` + ParentID string `json:"parent_id"` + // Total number of replies to the parent + replies to those replies (recursively). + Count int `json:"count"` + // Number of comment nodes from the parent down to the furthest comment node. + Depth int `json:"depth"` + Children []string `json:"children"` +} + +// Post is a submitted post on Reddit. +type Post struct { + ID string `json:"id,omitempty"` + FullID string `json:"name,omitempty"` + Created *Timestamp `json:"created_utc,omitempty"` + Edited *Timestamp `json:"edited,omitempty"` + + Permalink string `json:"permalink,omitempty"` + URL string `json:"url,omitempty"` + URLOverriddenByDest *string `json:"url_overridden_by_dest"` + RemovedByCategory *string `json:"removed_by_category"` + CrosspostParentList []Post `json:"crosspost_parent_list"` + + Title string `json:"title,omitempty"` + Body string `json:"selftext,omitempty"` + SelftextHtml *string `json:"selftext_html"` + + // Indicates if you've upvoted/downvoted (true/false). + // If neither, it will be nil. + Likes *bool `json:"likes"` + + Score int `json:"score"` + UpvoteRatio float32 `json:"upvote_ratio"` + NumberOfComments int `json:"num_comments"` + + SubredditName string `json:"subreddit,omitempty"` + SubredditNamePrefixed string `json:"subreddit_name_prefixed,omitempty"` + SubredditID string `json:"subreddit_id,omitempty"` + SubredditSubscribers int `json:"subreddit_subscribers"` + + Author string `json:"author,omitempty"` + AuthorID string `json:"author_fullname,omitempty"` + + Spoiler bool `json:"spoiler"` + Locked bool `json:"locked"` + NSFW bool `json:"over_18"` + IsSelfPost bool `json:"is_self"` + Saved bool `json:"saved"` + Stickied bool `json:"stickied"` + + Thumbnail string `json:"thumbnail"` + + MediaMetadata *MediaMetadata `json:"media_metadata,omitempty"` + GalleryData *GalleryData `json:"gallery_data,omitempty"` + + Preview *Preview `json:"preview,omitempty"` + SecureMedia *SecureMedia `json:"secure_media,omitempty"` + + PollData *PollData `json:"poll_data"` +} + +type PollDataInner struct { + Timestamp int `json:"voting_end_timestamp"` + Options []PollOption `json:"options"` + IsPrediction bool `json:"is_prediction"` + TotalVoteCount int `json:"total_vote_count"` +} + +type PollData struct { + PollDataInner +} + +func (d *PollData) UnmarshalJSON(data []byte) error { + var junk bool + err := json.Unmarshal(data, &junk) + if err == nil { + return nil + } + inner := new(PollDataInner) + err = json.Unmarshal(data, inner) + if err != nil { + return err + } + d.PollDataInner = *inner + return nil +} + +type PollOption struct { + Text string `json:"text"` + Id string `json:"id"` +} + +type SecureMedia struct { + RedditVideo RedditVideo `json:"reddit_video"` + OEmbed OEmbed `json:"oembed"` +} + +type OEmbed struct { + ProviderUrl string `json:"provider_url"` + Version string `json:"version"` + Title string `json:"title"` + ThumbnailWidth int `json:"thumbnail_width"` + Height int `json:"height"` + Width int `json:"width"` + Html string `json:"html"` + ProviderName string `json:"provider_name"` + ThumbnailUrl string `json:"thumbnail_url"` + Type string `json:"type"` + ThumbnailHeight int `json:"thumbnail_height"` +} + +type RedditVideo struct { + BitrateKbps int `json:"bitrate_kbps"` + DashUrl string `json:"dash_url"` + Duration int `json:"duration"` + FallbackUrl string `json:"fallback_url"` + HasAudio bool `json:"has_audio"` + Height int `json:"height"` + HlsUrl string `json:"hls_url"` + IsGif bool `json:"is_gif"` + ScrubberMediaUrl string `json:"scrubber_media_url"` + TranscodingStatus string `json:"transcoding_status"` + Width int `json:"width"` +} + +type Preview struct { + Images []Image `json:"images"` + Enabled bool `json:"enabled"` + RedditVideoPreview *VideoPreview `json:"reddit_video_preview"` +} + +type Image struct { + Source Resolution `json:"source"` + Resolutions []Resolution `json:"resolutions"` + Variants Variants `json:"variants"` + Id string `json:"id"` +} + +type Resolution struct { + Url string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` +} + +type Variants struct { + Obfuscated *ImageVariant `json:"obfuscated"` + Nsfw *ImageVariant `json:"nsfw"` + Mp4 *ImageVariant `json:"mp4"` +} + +type ImageVariant struct { + Source Resolution `json:"source"` + Resolutions []Resolution `json:"resolutions"` +} + +type VideoPreview struct { + BitrateKbps int `json:"bitrate_kbps"` + FallbackUrl string `json:"fallback_url"` + Height int `json:"height"` + Width int `json:"width"` + ScrubberMediaUrl string `json:"scrubber_media_url"` + DashUrl string `json:"dash_url"` + Duration int `json:"duration"` + HlsUrl string `json:"hls_url"` + IsGif bool `json:"is_gif"` + TranscodingStatus string `json:"transcoding_status"` +} + +type GalleryData struct { + Items []GalleryItem `json:"items"` +} + +type GalleryItem struct { + MediaId string `json:"media_id"` + Id int `json:"id"` +} + +type MediaMetadata map[string]Metadata + +type Metadata struct { + Status string `json:"status"` + E string `json:"e"` + M string `json:"m"` + P []Picture `json:"p"` + S Picture `json:"s"` + Id string `json:"id"` +} + +type Picture struct { + Y int `json:"y"` + X int `json:"x"` + U string `json:"u"` +} + +// Subreddit holds information about a subreddit +type Subreddit struct { + ID string `json:"id,omitempty"` + FullID string `json:"name,omitempty"` + Created *Timestamp `json:"created_utc,omitempty"` + + URL string `json:"url,omitempty"` + Name string `json:"display_name,omitempty"` + NamePrefixed string `json:"display_name_prefixed,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"public_description,omitempty"` + Type string `json:"subreddit_type,omitempty"` + SuggestedCommentSort string `json:"suggested_comment_sort,omitempty"` + + Subscribers int `json:"subscribers"` + ActiveUserCount *int `json:"active_user_count,omitempty"` + NSFW bool `json:"over18"` + UserIsMod bool `json:"user_is_moderator"` + Subscribed bool `json:"user_is_subscriber"` + Favorite bool `json:"user_has_favorited"` +} + +// PostAndComments is a post and its comments. +type PostAndComments struct { + Post *Post `json:"post"` + Comments []*Comment `json:"comments"` + More *More `json:"-"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// When getting a sticky post, you get an array of 2 Listings +// The 1st one contains the single post in its children array +// The 2nd one contains the comments to the post +func (pc *PostAndComments) UnmarshalJSON(data []byte) error { + var root [2]thing + + err := json.Unmarshal(data, &root) + if err != nil { + return err + } + + listing1, _ := root[0].Listing() + listing2, _ := root[1].Listing() + + pc.Post = listing1.Posts()[0] + pc.Comments = listing2.Comments() + if len(listing2.Mores()) > 0 { + pc.More = listing2.Mores()[0] + } + + return nil +} + +// HasMore determines whether the post has more replies to load in its reply tree. +func (pc *PostAndComments) HasMore() bool { + return pc.More != nil && len(pc.More.Children) > 0 +} + +func (pc *PostAndComments) addCommentToTree(comment *Comment) { + if pc.Post.FullID == comment.ParentID { + pc.Comments = append(pc.Comments, comment) + return + } + + for _, reply := range pc.Comments { + reply.addCommentToReplies(comment) + } +} + +func (pc *PostAndComments) addMoreToTree(more *More) { + if pc.Post.FullID == more.ParentID { + pc.More = more + } + + for _, reply := range pc.Comments { + reply.addMoreToReplies(more) + } +} diff --git a/v2/reddit/timestamp.go b/v2/reddit/timestamp.go new file mode 100644 index 0000000..7f90cb4 --- /dev/null +++ b/v2/reddit/timestamp.go @@ -0,0 +1,47 @@ +package reddit + +import ( + "strconv" + "time" +) + +// Timestamp represents a time that can be unmarshalled from a JSON string +// formatted as either an RFC3339 or Unix timestamp. +type Timestamp struct { + time.Time +} + +// MarshalJSON implements the json.Marshaler interface. +func (t *Timestamp) MarshalJSON() ([]byte, error) { + if t == nil || t.Time.IsZero() { + return []byte(`false`), nil + } + + parsed := t.Time.Format(time.RFC3339) + return []byte(`"` + parsed + `"`), nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// Time is expected in RFC3339 or Unix format. +func (t *Timestamp) UnmarshalJSON(data []byte) (err error) { + str := string(data) + + // "edited" for posts and comments is either false, or a timestamp. + if str == "false" { + return + } + + f, err := strconv.ParseFloat(str, 64) + if err == nil { + t.Time = time.Unix(int64(f), 0).UTC() + } else { + t.Time, err = time.Parse(`"`+time.RFC3339+`"`, str) + } + + return +} + +// Equal reports whether t and u are equal based on time.Equal +func (t Timestamp) Equal(u Timestamp) bool { + return t.Time.Equal(u.Time) +} diff --git a/v2/reddit/timestamp_test.go b/v2/reddit/timestamp_test.go new file mode 100644 index 0000000..3ef9760 --- /dev/null +++ b/v2/reddit/timestamp_test.go @@ -0,0 +1,182 @@ +package reddit + +import ( + "encoding/json" + "fmt" + "testing" + "time" +) + +const ( + emptyTimeStr = `"0001-01-01T00:00:00Z"` + referenceTimeStr = `"2006-01-02T15:04:05Z"` + referenceUnixTimeStr = `1136214245` +) + +var ( + referenceTime = time.Date(2006, time.January, 02, 15, 04, 05, 0, time.UTC) + unixOrigin = time.Unix(0, 0).In(time.UTC) +) + +func TestTimestamp_Marshal(t *testing.T) { + testCases := []struct { + desc string + data Timestamp + want string + wantErr bool + equal bool + }{ + {"Reference", Timestamp{referenceTime}, referenceTimeStr, false, true}, + {"Empty", Timestamp{}, emptyTimeStr, false, true}, + {"Mismatch", Timestamp{}, referenceTimeStr, false, false}, + } + for _, tc := range testCases { + out, err := json.Marshal(tc.data) + if gotErr := err != nil; gotErr != tc.wantErr { + t.Fatalf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err) + } + got := string(out) + equal := got == tc.want + if (got == tc.want) != tc.equal { + t.Fatalf("%s: got=%s, want=%s, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal) + } + } +} + +func TestTimestamp_Unmarshal(t *testing.T) { + testCases := []struct { + desc string + data string + want Timestamp + wantErr bool + equal bool + }{ + {"Reference", referenceTimeStr, Timestamp{referenceTime}, false, true}, + {"ReferenceUnix", referenceUnixTimeStr, Timestamp{referenceTime}, false, true}, + {"Empty", emptyTimeStr, Timestamp{}, false, true}, + {"UnixStart", `0`, Timestamp{unixOrigin}, false, true}, + {"Mismatch", referenceTimeStr, Timestamp{}, false, false}, + {"MismatchUnix", `0`, Timestamp{}, false, false}, + {"Invalid", `"asdf"`, Timestamp{referenceTime}, true, false}, + } + for _, tc := range testCases { + var got Timestamp + err := json.Unmarshal([]byte(tc.data), &got) + if gotErr := err != nil; gotErr != tc.wantErr { + t.Fatalf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err) + continue + } + equal := got.Equal(tc.want) + if equal != tc.equal { + t.Fatalf("%s: got=%#v, want=%#v, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal) + } + } +} + +func TestTimstamp_MarshalReflexivity(t *testing.T) { + testCases := []struct { + desc string + data Timestamp + }{ + {"Reference", Timestamp{referenceTime}}, + {"Empty", Timestamp{}}, + } + for _, tc := range testCases { + data, err := json.Marshal(tc.data) + if err != nil { + t.Fatalf("%s: Marshal err=%v", tc.desc, err) + } + var got Timestamp + err = json.Unmarshal(data, &got) + if err != nil { + t.Fatalf("%s: Unmarshal err=%v", tc.desc, err) + } + if !got.Equal(tc.data) { + t.Fatalf("%s: %+v != %+v", tc.desc, got, data) + } + } +} + +type WrappedTimestamp struct { + A int + Time Timestamp +} + +func TestWrappedTimstamp_Marshal(t *testing.T) { + testCases := []struct { + desc string + data WrappedTimestamp + want string + wantErr bool + equal bool + }{ + {"Reference", WrappedTimestamp{0, Timestamp{referenceTime}}, fmt.Sprintf(`{"A":0,"Time":%s}`, referenceTimeStr), false, true}, + {"Empty", WrappedTimestamp{}, fmt.Sprintf(`{"A":0,"Time":%s}`, emptyTimeStr), false, true}, + {"Mismatch", WrappedTimestamp{}, fmt.Sprintf(`{"A":0,"Time":%s}`, referenceTimeStr), false, false}, + } + for _, tc := range testCases { + out, err := json.Marshal(tc.data) + if gotErr := err != nil; gotErr != tc.wantErr { + t.Fatalf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err) + } + got := string(out) + equal := got == tc.want + if equal != tc.equal { + t.Fatalf("%s: got=%s, want=%s, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal) + } + } +} + +func TestWrappedTimstamp_Unmarshal(t *testing.T) { + testCases := []struct { + desc string + data string + want WrappedTimestamp + wantErr bool + equal bool + }{ + {"Reference", referenceTimeStr, WrappedTimestamp{0, Timestamp{referenceTime}}, false, true}, + {"ReferenceUnix", referenceUnixTimeStr, WrappedTimestamp{0, Timestamp{referenceTime}}, false, true}, + {"Empty", emptyTimeStr, WrappedTimestamp{0, Timestamp{}}, false, true}, + {"UnixStart", `0`, WrappedTimestamp{0, Timestamp{unixOrigin}}, false, true}, + {"Mismatch", referenceTimeStr, WrappedTimestamp{0, Timestamp{}}, false, false}, + {"MismatchUnix", `0`, WrappedTimestamp{0, Timestamp{}}, false, false}, + {"Invalid", `"asdf"`, WrappedTimestamp{0, Timestamp{referenceTime}}, true, false}, + } + for _, tc := range testCases { + var got Timestamp + err := json.Unmarshal([]byte(tc.data), &got) + if gotErr := err != nil; gotErr != tc.wantErr { + t.Fatalf("%s: gotErr=%v, wantErr=%v, err=%v", tc.desc, gotErr, tc.wantErr, err) + continue + } + equal := got.Time.Equal(tc.want.Time.Time) + if equal != tc.equal { + t.Fatalf("%s: got=%#v, want=%#v, equal=%v, want=%v", tc.desc, got, tc.want, equal, tc.equal) + } + } +} + +func TestWrappedTimstamp_MarshalReflexivity(t *testing.T) { + testCases := []struct { + desc string + data WrappedTimestamp + }{ + {"Reference", WrappedTimestamp{0, Timestamp{referenceTime}}}, + {"Empty", WrappedTimestamp{0, Timestamp{}}}, + } + for _, tc := range testCases { + bytes, err := json.Marshal(tc.data) + if err != nil { + t.Fatalf("%s: Marshal err=%v", tc.desc, err) + } + var got WrappedTimestamp + err = json.Unmarshal(bytes, &got) + if err != nil { + t.Fatalf("%s: Unmarshal err=%v", tc.desc, err) + } + if !got.Time.Equal(tc.data.Time) { + t.Fatalf("%s: %+v != %+v", tc.desc, got, tc.data) + } + } +} diff --git a/v2/reddit/user.go b/v2/reddit/user.go new file mode 100644 index 0000000..f1839f5 --- /dev/null +++ b/v2/reddit/user.go @@ -0,0 +1,413 @@ +package reddit + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// UserService handles communication with the user +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_users +type UserService struct { + client *Client +} + +// User represents a Reddit user. +type User struct { + // this is not the full ID, watch out. + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Created *Timestamp `json:"created_utc,omitempty"` + + PostKarma int `json:"link_karma"` + CommentKarma int `json:"comment_karma"` + + IsFriend bool `json:"is_friend"` + IsEmployee bool `json:"is_employee"` + HasVerifiedEmail bool `json:"has_verified_email"` + NSFW bool `json:"over_18"` + IsSuspended bool `json:"is_suspended"` +} + +// UserSummary represents a Reddit user, but +// contains fewer pieces of information. +type UserSummary struct { + Name string `json:"name,omitempty"` + Created *Timestamp `json:"created_utc,omitempty"` + + PostKarma int `json:"link_karma"` + CommentKarma int `json:"comment_karma"` + + NSFW bool `json:"profile_over_18"` +} + +// Blocked represents a blocked relationship. +type Blocked struct { + Blocked string `json:"name,omitempty"` + BlockedID string `json:"id,omitempty"` + Created *Timestamp `json:"date,omitempty"` +} + +// Trophy is a Reddit award. +type Trophy struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// Get returns information about the user. +func (s *UserService) Get(ctx context.Context, username string) (*User, *Response, error) { + path := fmt.Sprintf("user/%s/about", username) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + user, _ := t.User() + return user, resp, nil +} + +// GetMultipleByID returns multiple users from their full IDs. +// The response body is a map where the keys are the IDs (if they exist), and the value is the user. +func (s *UserService) GetMultipleByID(ctx context.Context, ids ...string) (map[string]*UserSummary, *Response, error) { + params := struct { + IDs []string `url:"ids,omitempty,comma"` + }{ids} + + path := "api/user_data_by_account_ids" + path, err := addOptions(path, params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := make(map[string]*UserSummary) + resp, err := s.client.Do(ctx, req, &root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// UsernameAvailable checks whether a username is available for registration. +func (s *UserService) UsernameAvailable(ctx context.Context, username string) (bool, *Response, error) { + params := struct { + User string `url:"user"` + }{username} + + path := "api/username_available" + path, err := addOptions(path, params) + if err != nil { + return false, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return false, nil, err + } + + root := new(bool) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return false, resp, err + } + + return *root, resp, nil +} + +// Overview returns a list of your posts and comments. +func (s *UserService) Overview(ctx context.Context, opts *ListUserOverviewOptions) ([]*Post, []*Comment, *Response, error) { + return s.OverviewOf(ctx, s.client.Username, opts) +} + +// OverviewOf returns a list of the user's posts and comments. +func (s *UserService) OverviewOf(ctx context.Context, username string, opts *ListUserOverviewOptions) ([]*Post, []*Comment, *Response, error) { + path := fmt.Sprintf("user/%s/overview", username) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, nil, resp, err + } + return l.Posts(), l.Comments(), resp, nil +} + +// Posts returns a list of your posts. +func (s *UserService) Posts(ctx context.Context, opts *ListUserOverviewOptions) ([]*Post, *Response, error) { + return s.PostsOf(ctx, s.client.Username, opts) +} + +// PostsOf returns a list of the user's posts. +func (s *UserService) PostsOf(ctx context.Context, username string, opts *ListUserOverviewOptions) ([]*Post, *Response, error) { + path := fmt.Sprintf("user/%s/submitted", username) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} + +// Comments returns a list of your comments. +func (s *UserService) Comments(ctx context.Context, opts *ListUserOverviewOptions) ([]*Comment, *Response, error) { + return s.CommentsOf(ctx, s.client.Username, opts) +} + +// CommentsOf returns a list of the user's comments. +func (s *UserService) CommentsOf(ctx context.Context, username string, opts *ListUserOverviewOptions) ([]*Comment, *Response, error) { + path := fmt.Sprintf("user/%s/comments", username) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Comments(), resp, nil +} + +// Saved returns a list of the user's saved posts and comments. +func (s *UserService) Saved(ctx context.Context, opts *ListUserOverviewOptions) ([]*Post, []*Comment, *Response, error) { + path := fmt.Sprintf("user/%s/saved", s.client.Username) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, nil, resp, err + } + return l.Posts(), l.Comments(), resp, nil +} + +// Upvoted returns a list of your upvoted posts. +func (s *UserService) Upvoted(ctx context.Context, opts *ListUserOverviewOptions) ([]*Post, *Response, error) { + return s.UpvotedOf(ctx, s.client.Username, opts) +} + +// UpvotedOf returns a list of the user's upvoted posts. +// The user's votes must be public for this to work (unless the user is you). +func (s *UserService) UpvotedOf(ctx context.Context, username string, opts *ListUserOverviewOptions) ([]*Post, *Response, error) { + path := fmt.Sprintf("user/%s/upvoted", username) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} + +// Downvoted returns a list of your downvoted posts. +func (s *UserService) Downvoted(ctx context.Context, opts *ListUserOverviewOptions) ([]*Post, *Response, error) { + return s.DownvotedOf(ctx, s.client.Username, opts) +} + +// DownvotedOf returns a list of the user's downvoted posts. +// The user's votes must be public for this to work (unless the user is you). +func (s *UserService) DownvotedOf(ctx context.Context, username string, opts *ListUserOverviewOptions) ([]*Post, *Response, error) { + path := fmt.Sprintf("user/%s/downvoted", username) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} + +// Hidden returns a list of the user's hidden posts. +func (s *UserService) Hidden(ctx context.Context, opts *ListUserOverviewOptions) ([]*Post, *Response, error) { + path := fmt.Sprintf("user/%s/hidden", s.client.Username) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} + +// Gilded returns a list of the user's gilded posts. +func (s *UserService) Gilded(ctx context.Context, opts *ListUserOverviewOptions) ([]*Post, *Response, error) { + path := fmt.Sprintf("user/%s/gilded", s.client.Username) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} + +// GetFriendship returns relationship details with the specified user. +// If the user is not your friend, it will return an error. +func (s *UserService) GetFriendship(ctx context.Context, username string) (*Relationship, *Response, error) { + path := fmt.Sprintf("api/v1/me/friends/%s", username) + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(Relationship) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Friend a user. +func (s *UserService) Friend(ctx context.Context, username string) (*Relationship, *Response, error) { + body := struct { + Username string `json:"name"` + }{username} + + path := fmt.Sprintf("api/v1/me/friends/%s", username) + req, err := s.client.NewJSONRequest(http.MethodPut, path, body) + if err != nil { + return nil, nil, err + } + + root := new(Relationship) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Unfriend a user. +func (s *UserService) Unfriend(ctx context.Context, username string) (*Response, error) { + path := fmt.Sprintf("api/v1/me/friends/%s", username) + req, err := s.client.NewRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +// Block a user. +func (s *UserService) Block(ctx context.Context, username string) (*Blocked, *Response, error) { + path := "api/block_user" + + form := url.Values{} + form.Set("name", username) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(Blocked) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// BlockByID blocks a user via their full id. +func (s *UserService) BlockByID(ctx context.Context, id string) (*Blocked, *Response, error) { + path := "api/block_user" + + form := url.Values{} + form.Set("account_id", id) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(Blocked) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root, resp, nil +} + +// Unblock a user. +func (s *UserService) Unblock(ctx context.Context, username string) (*Response, error) { + selfID, resp, err := s.client.id(ctx) + if err != nil { + return resp, err + } + + path := "api/unfriend" + + form := url.Values{} + form.Set("name", username) + form.Set("type", "enemy") + form.Set("container", selfID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// UnblockByID unblocks a user via their full id. +func (s *UserService) UnblockByID(ctx context.Context, id string) (*Response, error) { + selfID, resp, err := s.client.id(ctx) + if err != nil { + return resp, err + } + + path := "api/unfriend" + + form := url.Values{} + form.Set("id", id) + form.Set("type", "enemy") + form.Set("container", selfID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Trophies returns a list of your trophies. +func (s *UserService) Trophies(ctx context.Context) ([]*Trophy, *Response, error) { + return s.TrophiesOf(ctx, s.client.Username) +} + +// TrophiesOf returns a list of the specified user's trophies. +func (s *UserService) TrophiesOf(ctx context.Context, username string) ([]*Trophy, *Response, error) { + path := fmt.Sprintf("api/v1/user/%s/trophies", username) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + trophies, _ := t.TrophyList() + return trophies, resp, nil +} + +// Popular gets the user subreddits with the most activity. +func (s *UserService) Popular(ctx context.Context, opts *ListOptions) ([]*Subreddit, *Response, error) { + path := "users/popular" + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Subreddits(), resp, nil +} + +// New gets the most recently created user subreddits. +func (s *UserService) New(ctx context.Context, opts *ListUserOverviewOptions) ([]*Subreddit, *Response, error) { + path := "users/new" + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Subreddits(), resp, nil +} + +// Search for users. +// todo: maybe include the sort option? (relevance, activity) +func (s *UserService) Search(ctx context.Context, query string, opts *ListOptions) ([]*User, *Response, error) { + path := fmt.Sprintf("users/search?q=%s", query) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Users(), resp, nil +} diff --git a/v2/reddit/user_test.go b/v2/reddit/user_test.go new file mode 100644 index 0000000..81a2fd0 --- /dev/null +++ b/v2/reddit/user_test.go @@ -0,0 +1,925 @@ +package reddit + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedUser = &User{ + ID: "test", + Name: "Test_User", + Created: &Timestamp{time.Date(2012, 10, 18, 10, 11, 11, 0, time.UTC)}, + + PostKarma: 8239, + CommentKarma: 130514, + + HasVerifiedEmail: true, +} + +var expectedUsers = map[string]*UserSummary{ + "t2_1": { + Name: "test_user_1", + Created: &Timestamp{time.Date(2017, 3, 12, 2, 1, 47, 0, time.UTC)}, + PostKarma: 488, + CommentKarma: 22223, + NSFW: false, + }, + "t2_2": { + Name: "test_user_2", + Created: &Timestamp{time.Date(2015, 12, 20, 18, 12, 51, 0, time.UTC)}, + PostKarma: 8277, + CommentKarma: 131948, + NSFW: false, + }, + "t2_3": { + Name: "test_user_3", + Created: &Timestamp{time.Date(2013, 3, 4, 15, 46, 31, 0, time.UTC)}, + PostKarma: 126887, + CommentKarma: 81918, + NSFW: true, + }, +} + +var expectedPost = &Post{ + ID: "gczwql", + FullID: "t3_gczwql", + Created: &Timestamp{time.Date(2020, 5, 3, 22, 46, 25, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/redditdev/comments/gczwql/get_userusernamegilded_does_it_return_other_users/", + URL: "https://www.reddit.com/r/redditdev/comments/gczwql/get_userusernamegilded_does_it_return_other_users/", + + Title: "GET /user/{username}/gilded: does it return other user's things you've gilded, or your things that have been gilded? Does it return both comments and posts?", + Body: "Talking about [this](https://www.reddit.com/dev/api/#GET_user_{username}_{where}) endpoint specifically.\n\nI'm building a Reddit API client, but don't have gold.", + + Likes: Bool(true), + + Score: 9, + UpvoteRatio: 0.86, + NumberOfComments: 2, + + SubredditName: "redditdev", + SubredditNamePrefixed: "r/redditdev", + SubredditID: "t5_2qizd", + SubredditSubscribers: 37829, + + Author: "v_95", + AuthorID: "t2_164ab8", + + IsSelfPost: true, +} + +var expectedComment = &Comment{ + ID: "f0zsa37", + FullID: "t1_f0zsa37", + Created: &Timestamp{time.Date(2019, 9, 21, 21, 38, 16, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + ParentID: "t3_d7ejpn", + Permalink: "/r/apple/comments/d7ejpn/im_giving_away_an_iphone_11_pro_to_a_commenter_at/f0zsa37/", + + Body: "Thank you!", + Author: "v_95", + AuthorID: "t2_164ab8", + + SubredditName: "apple", + SubredditNamePrefixed: "r/apple", + SubredditID: "t5_2qh1f", + + Likes: Bool(true), + + Score: 1, + Controversiality: 0, + + PostID: "t3_d7ejpn", + PostTitle: "I'm giving away an iPhone 11 Pro to a commenter at random to celebrate Apollo for Reddit's new iOS 13 update and as a thank you to the community! Just leave a comment on this post and the winner will be selected randomly and announced tomorrow at 8 PM GMT. Details inside, and good luck!", + PostPermalink: "https://www.reddit.com/r/apple/comments/d7ejpn/im_giving_away_an_iphone_11_pro_to_a_commenter_at/", + PostAuthor: "iamthatis", + PostNumComments: Int(89751), +} + +var expectedRelationship = &Relationship{ + ID: "r9_tqfqp8", + User: "test123", + UserID: "t2_7b8q1eob", + Created: &Timestamp{time.Date(2020, 6, 18, 20, 36, 34, 0, time.UTC)}, +} + +var expectedBlocked = &Blocked{ + Blocked: "test123", + BlockedID: "t2_3v9o1yoi", + Created: &Timestamp{time.Date(2020, 6, 16, 16, 49, 50, 0, time.UTC)}, +} + +var expectedTrophies = []*Trophy{ + { + ID: "", + Name: "Three-Year Club", + Description: "", + }, + { + ID: "1q1tez", + Name: "Verified Email", + Description: "", + }, +} + +var expectedUserSubreddits = []*Subreddit{ + { + ID: "3kefx", + FullID: "t5_3kefx", + Created: &Timestamp{time.Date(2017, 5, 11, 16, 37, 16, 0, time.UTC)}, + + URL: "/user/nickofnight/", + Name: "u_nickofnight", + NamePrefixed: "u/nickofnight", + Title: "nickofnight", + Description: "Stories written for Writing Prompts, NoSleep, and originals. Current series: The Carnival of Night ", + Type: "user", + }, + { + ID: "3knn1", + FullID: "t5_3knn1", + Created: &Timestamp{time.Date(2017, 5, 18, 2, 15, 55, 0, time.UTC)}, + + URL: "/user/shittymorph/", + Name: "u_shittymorph", + NamePrefixed: "u/shittymorph", + Title: "shittymorph", + Description: "In nineteen ninety eight the undertaker threw mankind off hеll in a cell, and plummeted sixteen feet through an announcer's table.", + Type: "user", + SuggestedCommentSort: "qa", + }, +} + +var expectedSearchUsers = []*User{ + { + ID: "179965", + Name: "washingtonpost", + Created: &Timestamp{time.Date(2017, 4, 20, 21, 23, 58, 0, time.UTC)}, + + PostKarma: 1075227, + CommentKarma: 339569, + + HasVerifiedEmail: true, + }, + { + ID: "11kowl2w", + Name: "reuters", + Created: &Timestamp{time.Date(2018, 3, 15, 1, 50, 4, 0, time.UTC)}, + + PostKarma: 76744, + CommentKarma: 42717, + + HasVerifiedEmail: true, + }, +} + +func TestUserService_Get(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/get.json") + require.NoError(t, err) + + mux.HandleFunc("/user/Test_User/about", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + user, _, err := client.User.Get(ctx, "Test_User") + require.NoError(t, err) + require.Equal(t, expectedUser, user) +} + +func TestUserService_GetMultipleByID(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/get-multiple-by-id.json") + require.NoError(t, err) + + mux.HandleFunc("/api/user_data_by_account_ids", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, "t2_1,t2_2,t2_3", r.Form.Get("ids")) + + fmt.Fprint(w, blob) + }) + + users, _, err := client.User.GetMultipleByID(ctx, "t2_1", "t2_2", "t2_3") + require.NoError(t, err) + require.Equal(t, expectedUsers, users) +} + +func TestUserService_UsernameAvailable(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/username_available", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + err := r.ParseForm() + require.NoError(t, err) + + user := r.Form.Get("user") + require.NotEmpty(t, user) + + result := user == "test123" + fmt.Fprint(w, result) + }) + + ok, _, err := client.User.UsernameAvailable(ctx, "test123") + require.NoError(t, err) + require.True(t, ok) + + ok, _, err = client.User.UsernameAvailable(ctx, "123test") + require.NoError(t, err) + require.False(t, ok) +} + +func TestUserService_Overview(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/overview", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, comments, resp, err := client.User.Overview(ctx, nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + + require.Len(t, comments, 1) + require.Equal(t, expectedComment, comments[0]) + + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestUserService_OverviewOf(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user2/overview", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, comments, resp, err := client.User.OverviewOf(ctx, "user2", nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + + require.Len(t, comments, 1) + require.Equal(t, expectedComment, comments[0]) + + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestUserService_Overview_Options(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/overview", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "5") + form.Set("after", "t3_after") + form.Set("sort", "top") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + _, _, _, err = client.User.Overview(ctx, &ListUserOverviewOptions{ + ListOptions: ListOptions{ + Limit: 5, + After: "t3_after", + }, + Sort: "top", + }) + require.NoError(t, err) +} + +func TestUserService_Posts(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/submitted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.User.Posts(ctx, nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t3_gczwql", resp.After) +} + +func TestUserService_PostsOf(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user2/submitted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.User.PostsOf(ctx, "user2", nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t3_gczwql", resp.After) +} + +func TestUserService_Posts_Options(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/submitted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "10") + form.Set("sort", "new") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.User.Posts(ctx, &ListUserOverviewOptions{ + ListOptions: ListOptions{ + Limit: 10, + }, + Sort: "new", + }) + require.NoError(t, err) +} + +func TestUserService_Comments(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/comments.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/comments", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + comments, resp, err := client.User.Comments(ctx, nil) + require.NoError(t, err) + + require.Len(t, comments, 1) + require.Equal(t, expectedComment, comments[0]) + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestUserService_CommentsOf(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/comments.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user2/comments", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + comments, resp, err := client.User.CommentsOf(ctx, "user2", nil) + require.NoError(t, err) + + require.Len(t, comments, 1) + require.Equal(t, expectedComment, comments[0]) + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestUserService_Comments_Options(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/comments.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/comments", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "100") + form.Set("before", "t1_before") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.User.Comments(ctx, &ListUserOverviewOptions{ + ListOptions: ListOptions{ + Limit: 100, + Before: "t1_before", + }, + }) + require.NoError(t, err) +} + +func TestUserService_Saved(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/saved", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, comments, resp, err := client.User.Saved(ctx, nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + + require.Len(t, comments, 1) + require.Equal(t, expectedComment, comments[0]) + + require.Equal(t, "t1_f0zsa37", resp.After) +} + +func TestUserService_Saved_Options(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/overview.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/saved", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "50") + form.Set("sort", "controversial") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + _, _, _, err = client.User.Saved(ctx, &ListUserOverviewOptions{ + ListOptions: ListOptions{ + Limit: 50, + }, + Sort: "controversial", + }) + require.NoError(t, err) +} +func TestUserService_Upvoted(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/upvoted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.User.Upvoted(ctx, nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t3_gczwql", resp.After) +} + +func TestUserService_Upvoted_Options(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/upvoted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "30") + form.Set("after", "t3_after") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.User.Upvoted(ctx, &ListUserOverviewOptions{ + ListOptions: ListOptions{ + Limit: 30, + After: "t3_after", + }, + }) + require.NoError(t, err) +} + +func TestUserService_UpvotedOf(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user2/upvoted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.User.UpvotedOf(ctx, "user2", nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t3_gczwql", resp.After) +} + +func TestUserService_Downvoted(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/downvoted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.User.Downvoted(ctx, nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t3_gczwql", resp.After) +} + +func TestUserService_Downvoted_Options(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/downvoted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "20") + form.Set("before", "t3_before") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.User.Downvoted(ctx, &ListUserOverviewOptions{ + ListOptions: ListOptions{ + Limit: 20, + Before: "t3_before", + }, + }) + require.NoError(t, err) +} + +func TestUserService_DownvotedOf(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user2/downvoted", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.User.DownvotedOf(ctx, "user2", nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t3_gczwql", resp.After) +} + +func TestUserService_Hidden(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/hidden", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.User.Hidden(ctx, nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t3_gczwql", resp.After) +} + +func TestUserService_Gilded(t *testing.T) { + client, mux := setup(t) + + // we'll use this, similar payloads + blob, err := readFileContents("../testdata/user/submitted.json") + require.NoError(t, err) + + mux.HandleFunc("/user/user1/gilded", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + posts, resp, err := client.User.Gilded(ctx, nil) + require.NoError(t, err) + + require.Len(t, posts, 1) + require.Equal(t, expectedPost, posts[0]) + require.Equal(t, "t3_gczwql", resp.After) +} + +func TestUserService_GetFriendship(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/friend.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/me/friends/test123", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + relationship, _, err := client.User.GetFriendship(ctx, "test123") + require.NoError(t, err) + require.Equal(t, expectedRelationship, relationship) +} + +func TestUserService_Friend(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/friend.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/me/friends/test123", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPut, r.Method) + + var request struct { + Username string `json:"name"` + } + + err := json.NewDecoder(r.Body).Decode(&request) + require.NoError(t, err) + require.Equal(t, "test123", request.Username) + + fmt.Fprint(w, blob) + }) + + relationship, _, err := client.User.Friend(ctx, "test123") + require.NoError(t, err) + require.Equal(t, expectedRelationship, relationship) +} + +func TestUserService_Unfriend(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/api/v1/me/friends/test123", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := client.User.Unfriend(ctx, "test123") + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, resp.StatusCode) +} + +func TestUserService_Block(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/block.json") + require.NoError(t, err) + + mux.HandleFunc("/api/block_user", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("name", "test123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + blocked, _, err := client.User.Block(ctx, "test123") + require.NoError(t, err) + require.Equal(t, expectedBlocked, blocked) +} + +func TestUserService_BlockByID(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/block.json") + require.NoError(t, err) + + mux.HandleFunc("/api/block_user", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("account_id", "abc123") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + blocked, _, err := client.User.BlockByID(ctx, "abc123") + require.NoError(t, err) + require.Equal(t, expectedBlocked, blocked) +} + +func TestUserService_Unblock(t *testing.T) { + client, mux := setup(t) + + client.redditID = "self123" + + mux.HandleFunc("/api/unfriend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("name", "test123") + form.Set("type", "enemy") + form.Set("container", client.redditID) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.User.Unblock(ctx, "test123") + require.NoError(t, err) +} + +func TestUserService_UnblockByID(t *testing.T) { + client, mux := setup(t) + + client.redditID = "self123" + + mux.HandleFunc("/api/unfriend", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("id", "abc123") + form.Set("type", "enemy") + form.Set("container", client.redditID) + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.User.UnblockByID(ctx, "abc123") + require.NoError(t, err) +} + +func TestUserService_Trophies(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/trophies.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/user/user1/trophies", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + trophies, _, err := client.User.Trophies(ctx) + require.NoError(t, err) + require.Equal(t, expectedTrophies, trophies) +} + +func TestUserService_TrophiesOf(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/trophies.json") + require.NoError(t, err) + + mux.HandleFunc("/api/v1/user/test123/trophies", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + trophies, _, err := client.User.TrophiesOf(ctx, "test123") + require.NoError(t, err) + require.Equal(t, expectedTrophies, trophies) +} + +func TestUserService_Popular(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/user-subreddits.json") + require.NoError(t, err) + + mux.HandleFunc("/users/popular", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + userSubreddits, resp, err := client.User.Popular(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedUserSubreddits, userSubreddits) + require.Equal(t, "t5_3knn1", resp.After) +} + +func TestUserService_New(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/user-subreddits.json") + require.NoError(t, err) + + mux.HandleFunc("/users/new", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + userSubreddits, resp, err := client.User.New(ctx, nil) + require.NoError(t, err) + require.Equal(t, expectedUserSubreddits, userSubreddits) + require.Equal(t, "t5_3knn1", resp.After) +} + +func TestUserService_Search(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/user/list.json") + require.NoError(t, err) + + mux.HandleFunc("/users/search", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("q", "test") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + users, resp, err := client.User.Search(ctx, "test", nil) + require.NoError(t, err) + require.Equal(t, expectedSearchUsers, users) + require.Equal(t, "t2_11kowl2w", resp.After) +} diff --git a/v2/reddit/widget.go b/v2/reddit/widget.go new file mode 100644 index 0000000..34436d6 --- /dev/null +++ b/v2/reddit/widget.go @@ -0,0 +1,471 @@ +package reddit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" +) + +// WidgetService handles communication with the widget +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_widgets +type WidgetService struct { + client *Client +} + +// Widget is a section of useful content on a subreddit. +// They can feature information such as rules, links, the origins of the subreddit, etc. +// Read about them here: https://mods.reddithelp.com/hc/en-us/articles/360010364372-Sidebar-Widgets +type Widget interface { + // kind returns the widget kind. + // having un unexported method on an exported interface means it cannot be implemented by a client. + kind() string + // GetID returns the widget's id. + GetID() string +} + +const ( + widgetKindTextArea = "textarea" + widgetKindButton = "button" + widgetKindImage = "image" + widgetKindCommunityList = "community-list" + widgetKindMenu = "menu" + widgetKindCommunityDetails = "id-card" + widgetKindModerators = "moderators" + widgetKindSubredditRules = "subreddit-rules" + widgetKindCustom = "custom" +) + +type rootWidget struct { + Data Widget +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (w *rootWidget) UnmarshalJSON(data []byte) error { + root := new(struct { + Kind string `json:"kind"` + }) + + err := json.Unmarshal(data, root) + if err != nil { + return err + } + + switch root.Kind { + case widgetKindTextArea: + w.Data = new(TextAreaWidget) + case widgetKindButton: + w.Data = new(ButtonWidget) + case widgetKindImage: + w.Data = new(ImageWidget) + case widgetKindCommunityList: + w.Data = new(CommunityListWidget) + case widgetKindMenu: + w.Data = new(MenuWidget) + case widgetKindCommunityDetails: + w.Data = new(CommunityDetailsWidget) + case widgetKindModerators: + w.Data = new(ModeratorsWidget) + case widgetKindSubredditRules: + w.Data = new(SubredditRulesWidget) + case widgetKindCustom: + w.Data = new(CustomWidget) + default: + return fmt.Errorf("unrecognized widget kind: %q", root.Kind) + } + + return json.Unmarshal(data, w.Data) +} + +// WidgetList is a list of widgets. +type WidgetList []Widget + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (l *WidgetList) UnmarshalJSON(data []byte) error { + var widgetMap map[string]json.RawMessage + err := json.Unmarshal(data, &widgetMap) + if err != nil { + return err + } + + *l = make(WidgetList, 0, len(widgetMap)) + for _, w := range widgetMap { + root := new(rootWidget) + err = json.Unmarshal(w, root) + if err != nil { + return err + } + + *l = append(*l, root.Data) + } + + return nil +} + +// common widget fields +type widget struct { + ID string `json:"id,omitempty"` + Kind string `json:"kind,omitempty"` + Style *WidgetStyle `json:"styles,omitempty"` +} + +func (w *widget) kind() string { return w.Kind } +func (w *widget) GetID() string { return w.ID } + +// TextAreaWidget displays a box of text in the subreddit. +type TextAreaWidget struct { + widget + + Name string `json:"shortName,omitempty"` + Text string `json:"text,omitempty"` +} + +// ButtonWidget displays up to 10 button style links with customizable font colors for each button. +type ButtonWidget struct { + widget + + Name string `json:"shortName,omitempty"` + Description string `json:"description,omitempty"` + Buttons []*WidgetButton `json:"buttons,omitempty"` +} + +// ImageWidget display a random image from up to 10 selected images. +// The image can be clickable links. +type ImageWidget struct { + widget + + Name string `json:"shortName,omitempty"` + Images []*WidgetImageLink `json:"data,omitempty"` +} + +// CommunityListWidget display a list of up to 10 other communities (subreddits). +type CommunityListWidget struct { + widget + + Name string `json:"shortName,omitempty"` + Communities []*WidgetCommunity `json:"data,omitempty"` +} + +// MenuWidget displays tabs for your community's menu. These can be direct links or submenus that +// create a drop-down menu to multiple links. +type MenuWidget struct { + widget + + ShowWiki bool `json:"showWiki"` + Links WidgetLinkList `json:"data,omitempty"` +} + +// CommunityDetailsWidget displays your subscriber count, users online, and community description, +// as defined in your subreddit settings. You can customize the displayed text for subscribers and +// users currently viewing the community. +type CommunityDetailsWidget struct { + widget + + Name string `json:"shortName,omitempty"` + Description string `json:"description,omitempty"` + + Subscribers int `json:"subscribersCount"` + CurrentlyViewing int `json:"currentlyViewingCount"` + + SubscribersText string `json:"subscribersText,omitempty"` + CurrentlyViewingText string `json:"currentlyViewingText,omitempty"` +} + +// ModeratorsWidget displays the list of moderators of the subreddit. +type ModeratorsWidget struct { + widget + + Mods []string `json:"mods"` + Total int `json:"totalMods"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (w *ModeratorsWidget) UnmarshalJSON(data []byte) error { + root := new(struct { + widget + + Mods []struct { + Name string `json:"name"` + } `json:"mods"` + Total int `json:"totalMods"` + }) + + err := json.Unmarshal(data, root) + if err != nil { + return err + } + + w.widget = root.widget + w.Total = root.Total + for _, mod := range root.Mods { + w.Mods = append(w.Mods, mod.Name) + } + + return nil +} + +// SubredditRulesWidget displays your community rules. +type SubredditRulesWidget struct { + widget + + Name string `json:"shortName,omitempty"` + // One of: full (includes description), compact (rule is collapsed). + Display string `json:"display,omitempty"` + Rules []string `json:"rules,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (w *SubredditRulesWidget) UnmarshalJSON(data []byte) error { + root := new(struct { + widget + + Name string `json:"shortName"` + Display string `json:"display"` + Rules []struct { + Description string `json:"description"` + } `json:"data"` + }) + + err := json.Unmarshal(data, root) + if err != nil { + return err + } + + w.widget = root.widget + w.Name = root.Name + w.Display = root.Display + for _, r := range root.Rules { + w.Rules = append(w.Rules, r.Description) + } + + return nil +} + +// CustomWidget is a custom widget. +type CustomWidget struct { + widget + + Name string `json:"shortName,omitempty"` + Text string `json:"text,omitempty"` + + StyleSheet string `json:"css,omitempty"` + StyleSheetURL string `json:"stylesheetUrl,omitempty"` + Images []*WidgetImage `json:"imageData,omitempty"` +} + +// WidgetStyle contains style information for the widget. +type WidgetStyle struct { + HeaderColor string `json:"headerColor,omitempty"` + BackgroundColor string `json:"backgroundColor,omitempty"` +} + +// WidgetImage is an image in a widget. +type WidgetImage struct { + Name string `json:"name"` + URL string `json:"url"` +} + +// WidgetLink is a link or a group of links that's part of a widget. +type WidgetLink interface { + // single returns whether or not the widget holds just one single link. + // having un unexported method on an exported interface means it cannot be implemented by a client. + single() bool +} + +// WidgetLinkSingle is a link that's part of a widget. +type WidgetLinkSingle struct { + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` +} + +func (l *WidgetLinkSingle) single() bool { return true } + +// WidgetLinkMultiple is a dropdown of multiple links that's part of a widget. +type WidgetLinkMultiple struct { + Text string `json:"text,omitempty"` + URLs []*WidgetLinkSingle `json:"children,omitempty"` +} + +func (l *WidgetLinkMultiple) single() bool { return false } + +// WidgetLinkList is a list of widgets links. +type WidgetLinkList []WidgetLink + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (l *WidgetLinkList) UnmarshalJSON(data []byte) error { + var dataMap []json.RawMessage + err := json.Unmarshal(data, &dataMap) + if err != nil { + return err + } + + *l = make(WidgetLinkList, 0, len(dataMap)) + for _, d := range dataMap { + var widgetLinkDataMap map[string]json.RawMessage + err = json.Unmarshal(d, &widgetLinkDataMap) + if err != nil { + return err + } + + var wl WidgetLink + if _, ok := widgetLinkDataMap["children"]; ok { + wl = new(WidgetLinkMultiple) + } else { + wl = new(WidgetLinkSingle) + } + + err = json.Unmarshal(d, wl) + if err != nil { + return err + } + + *l = append(*l, wl) + } + + return nil +} + +// WidgetImageLink is an image that links to an URL within a widget. +type WidgetImageLink struct { + URL string `json:"url,omitempty"` + LinkURL string `json:"linkURL,omitempty"` +} + +// WidgetCommunity is a community (subreddit) that's displayed in a widget. +type WidgetCommunity struct { + Name string `json:"name,omitempty"` + Subscribers int `json:"subscribers"` + Subscribed bool `json:"isSubscribed"` + NSFW bool `json:"isNSFW"` +} + +// WidgetButton is a button that's part of a widget. +type WidgetButton struct { + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` + TextColor string `json:"textColor,omitempty"` + FillColor string `json:"fillColor,omitempty"` + // The color of the button's "outline". + StrokeColor string `json:"color,omitempty"` + HoverState *WidgetButtonHoverState `json:"hoverState,omitempty"` +} + +// WidgetButtonHoverState is the behaviour of a button that's part of a widget when it's hovered over with the mouse. +type WidgetButtonHoverState struct { + Text string `json:"text,omitempty"` + TextColor string `json:"textColor,omitempty"` + FillColor string `json:"fillColor,omitempty"` + // The color of the button's "outline". + StrokeColor string `json:"color,omitempty"` +} + +// WidgetCreateRequest represents a request to create a widget. +type WidgetCreateRequest interface { + requestKind() string +} + +// TextAreaWidgetCreateRequest represents a requets to create a text area widget. +type TextAreaWidgetCreateRequest struct { + Style *WidgetStyle `json:"styles,omitempty"` + // No longer than 30 characters. + Name string `json:"shortName,omitempty"` + // Raw markdown text. + Text string `json:"text,omitempty"` +} + +func (*TextAreaWidgetCreateRequest) requestKind() string { return widgetKindTextArea } + +// MarshalJSON implements the json.Marshaler interface. +func (r *TextAreaWidgetCreateRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Kind string `json:"kind"` + Style *WidgetStyle `json:"styles,omitempty"` + Name string `json:"shortName,omitempty"` + Text string `json:"text,omitempty"` + }{r.requestKind(), r.Style, r.Name, r.Text}) +} + +// CommunityListWidgetCreateRequest represents a requets to create a community list widget. +type CommunityListWidgetCreateRequest struct { + Style *WidgetStyle `json:"styles,omitempty"` + // No longer than 30 characters. + Name string `json:"shortName,omitempty"` + Communities []string `json:"data,omitempty"` +} + +func (*CommunityListWidgetCreateRequest) requestKind() string { return widgetKindCommunityList } + +// MarshalJSON implements the json.Marshaler interface. +func (r *CommunityListWidgetCreateRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Kind string `json:"kind"` + Style *WidgetStyle `json:"styles,omitempty"` + Name string `json:"shortName,omitempty"` + Communities []string `json:"data,omitempty"` + }{r.requestKind(), r.Style, r.Name, r.Communities}) +} + +// Get the subreddit's widgets. +func (s *WidgetService) Get(ctx context.Context, subreddit string) ([]Widget, *Response, error) { + path := fmt.Sprintf("r/%s/api/widgets?progressive_images=true", subreddit) + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(struct { + Widgets WidgetList `json:"items"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Widgets, resp, nil +} + +// Create a widget for the subreddit. +func (s *WidgetService) Create(ctx context.Context, subreddit string, request WidgetCreateRequest) (Widget, *Response, error) { + if request == nil { + return nil, nil, errors.New("WidgetCreateRequest: cannot be nil") + } + + path := fmt.Sprintf("r/%s/api/widget", subreddit) + req, err := s.client.NewJSONRequest(http.MethodPost, path, request) + if err != nil { + return nil, nil, err + } + + root := new(rootWidget) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Data, resp, nil +} + +// Delete a widget via its id. +func (s *WidgetService) Delete(ctx context.Context, subreddit, id string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/widget/%s", subreddit, id) + req, err := s.client.NewRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} + +// Reorder the widgets in the subreddit. +// The order should contain every single widget id in the subreddit; omitting any id will result in an error. +// The id list should only contain sidebar widgets. It should exclude the community details and moderators widgets. +func (s *WidgetService) Reorder(ctx context.Context, subreddit string, ids []string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/widget_order/sidebar", subreddit) + req, err := s.client.NewJSONRequest(http.MethodPatch, path, ids) + if err != nil { + return nil, err + } + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/widget_test.go b/v2/reddit/widget_test.go new file mode 100644 index 0000000..40692b6 --- /dev/null +++ b/v2/reddit/widget_test.go @@ -0,0 +1,265 @@ +package reddit + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +var expectedWidgets = []Widget{ + &TextAreaWidget{ + widget: widget{ + ID: "widget_15p7borvnnw5a", + Kind: "textarea", + Style: &WidgetStyle{ + HeaderColor: "#373c3f", + BackgroundColor: "#cc5289", + }, + }, + Name: "test title", + Text: "test text", + }, + + &ButtonWidget{ + widget: widget{ + ID: "widget_15paxrbiodp8v", + Kind: "button", + Style: &WidgetStyle{}, + }, + Name: "test text", + Description: "test description", + Buttons: []*WidgetButton{ + { + Text: "test text", + URL: "https://example.com", + TextColor: "#ff66ac", + FillColor: "#014980", + StrokeColor: "#73ad34", + HoverState: &WidgetButtonHoverState{ + Text: "test text", + TextColor: "#000000", + FillColor: "#00a6a5", + StrokeColor: "#000000", + }, + }, + }, + }, + + &ImageWidget{ + widget: widget{ + ID: "widget_15p7o01nqr5tu", + Kind: "image", + Style: &WidgetStyle{}, + }, + Name: "test title", + Images: []*WidgetImageLink{ + { + URL: "https://www.redditstatic.com/image-processing.png", + LinkURL: "https://example.com", + }, + }, + }, + + &CommunityListWidget{ + widget: widget{ + ID: "widget_15p7qwb2kxc6j", + Kind: "community-list", + Style: &WidgetStyle{ + HeaderColor: "#ffb000", + }, + }, + Name: "test title", + Communities: []*WidgetCommunity{ + { + Name: "nba", + Subscribers: 3571840, + Subscribed: true, + NSFW: false, + }, + { + Name: "golang", + Subscribers: 125961, + Subscribed: true, + NSFW: false, + }, + }, + }, + + &SubredditRulesWidget{ + widget: widget{ + ID: "widget_rules-2uquw1", + Kind: "subreddit-rules", + Style: &WidgetStyle{}, + }, + Name: "Subreddit Rules", + Display: "compact", + Rules: []string{"be nice"}, + }, + + &CommunityDetailsWidget{ + widget: widget{ + ID: "widget_id-card-2uquw1", + Kind: "id-card", + Style: &WidgetStyle{}, + }, + Name: "Community Details", + Description: "Community Description", + Subscribers: 2, + CurrentlyViewing: 3, + SubscribersText: "subscriberz", + CurrentlyViewingText: "viewerz", + }, + + &MenuWidget{ + widget: widget{ + ID: "widget_15owrhqvgfhke", + Kind: "menu", + Style: &WidgetStyle{}, + }, + ShowWiki: true, + Links: []WidgetLink{ + &WidgetLinkSingle{ + Text: "link1", + URL: "https://example.com", + }, + &WidgetLinkMultiple{ + Text: "test", + URLs: []*WidgetLinkSingle{ + { + Text: "link2", + URL: "https://example.com", + }, + { + Text: "link3", + URL: "https://example.com", + }, + }, + }, + }, + }, + + &ModeratorsWidget{ + widget: widget{ + ID: "widget_moderators-2uquw1", + Kind: "moderators", + Style: &WidgetStyle{}, + }, + Mods: []string{"testuser"}, + Total: 1, + }, + + &CustomWidget{ + widget: widget{ + ID: "widget_15osq4jms4tdo", + Kind: "custom", + Style: &WidgetStyle{}, + }, + Name: "custom image widget", + Text: "some image", + StyleSheet: "* {}", + StyleSheetURL: "https://styles.redditmedia.com/t5_2uquw1/styles/customWidget-stylesheet-n2q86gjf04o51.css", + Images: []*WidgetImage{ + { + Name: "test", + URL: "https://www.redditstatic.com/image-processing.png", + }, + }, + }, +} + +func TestWidgetService_Get(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/widget/widgets.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/api/widgets", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("progressive_images", "true") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + widgets, _, err := client.Widget.Get(ctx, "testsubreddit") + require.NoError(t, err) + require.ElementsMatch(t, expectedWidgets, widgets) +} + +func TestWidgetService_Create(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/widget", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + body := new(struct { + Name string `json:"shortName"` + Text string `json:"text"` + }) + + err := json.NewDecoder(r.Body).Decode(body) + require.NoError(t, err) + require.Equal(t, "test name", body.Name) + require.Equal(t, "test text", body.Text) + + fmt.Fprint(w, `{ + "text": "test text", + "kind": "textarea", + "shortName": "test name", + "id": "id123" + }`) + }) + + _, _, err := client.Widget.Create(ctx, "testsubreddit", nil) + require.EqualError(t, err, "WidgetCreateRequest: cannot be nil") + + createdWidget, _, err := client.Widget.Create(ctx, "testsubreddit", &TextAreaWidgetCreateRequest{ + Name: "test name", + Text: "test text", + }) + require.NoError(t, err) + require.Equal(t, &TextAreaWidget{ + widget: widget{ + ID: "id123", + Kind: "textarea", + }, + Name: "test name", + Text: "test text", + }, createdWidget) +} + +func TestWidgetService_Delete(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/widget/abc123", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + }) + + _, err := client.Widget.Delete(ctx, "testsubreddit", "abc123") + require.NoError(t, err) +} + +func TestWidgetService_Reorder(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/widget_order/sidebar", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method) + + var ids []string + err := json.NewDecoder(r.Body).Decode(&ids) + require.NoError(t, err) + require.Equal(t, []string{"test1", "test2", "test3", "test4"}, ids) + }) + + _, err := client.Widget.Reorder(ctx, "testsubreddit", []string{"test1", "test2", "test3", "test4"}) + require.NoError(t, err) +} diff --git a/v2/reddit/wiki.go b/v2/reddit/wiki.go new file mode 100644 index 0000000..ef7acda --- /dev/null +++ b/v2/reddit/wiki.go @@ -0,0 +1,396 @@ +package reddit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/google/go-querystring/query" +) + +// WikiService handles communication with the wiki +// related methods of the Reddit API. +// +// Reddit API docs: https://www.reddit.com/dev/api/#section_wiki +type WikiService struct { + client *Client +} + +// WikiPage is a wiki page in a subreddit. +type WikiPage struct { + Content string `json:"content_md,omitempty"` + Reason string `json:"reason,omitempty"` + MayRevise bool `json:"may_revise"` + + RevisionID string `json:"revision_id,omitempty"` + RevisionDate *Timestamp `json:"revision_date,omitempty"` + RevisionBy *User `json:"revision_by,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (p *WikiPage) UnmarshalJSON(b []byte) error { + root := new(struct { + Content string `json:"content_md,omitempty"` + Reason string `json:"reason,omitempty"` + MayRevise bool `json:"may_revise"` + + RevisionID string `json:"revision_id,omitempty"` + RevisionDate *Timestamp `json:"revision_date,omitempty"` + RevisionBy thing `json:"revision_by,omitempty"` + }) + + err := json.Unmarshal(b, root) + if err != nil { + return err + } + + p.Content = root.Content + p.Reason = root.Reason + p.MayRevise = root.MayRevise + + p.RevisionID = root.RevisionID + p.RevisionDate = root.RevisionDate + + if user, ok := root.RevisionBy.User(); ok { + p.RevisionBy = user + } + + return nil +} + +// WikiPageEditRequest represents a request to edit a wiki page in a subreddit. +type WikiPageEditRequest struct { + Subreddit string `url:"-"` + Page string `url:"page"` + Content string `url:"content"` + // Optional, up to 256 characters long. + Reason string `url:"reason,omitempty"` +} + +// WikiPagePermissionLevel defines who can edit a specific wiki page in a subreddit. +type WikiPagePermissionLevel int + +const ( + // PermissionSubredditWikiPermissions uses subreddit wiki permissions. + PermissionSubredditWikiPermissions WikiPagePermissionLevel = iota + // PermissionApprovedContributorsOnly is only for approved wiki contributors. + PermissionApprovedContributorsOnly + // PermissionModeratorsOnly is only for moderators. + PermissionModeratorsOnly +) + +// WikiPageSettings holds the settings for a specific wiki page. +type WikiPageSettings struct { + PermissionLevel WikiPagePermissionLevel `json:"permlevel"` + Listed bool `json:"listed"` + Editors []*User `json:"editors"` +} + +// WikiPageSettingsUpdateRequest represents a request to update the visibility and +// permissions of a wiki page. +type WikiPageSettingsUpdateRequest struct { + // This HAS to be provided no matter what, or else we get a 500 response. + PermissionLevel WikiPagePermissionLevel `url:"permlevel"` + Listed *bool `url:"listed,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (s *WikiPageSettings) UnmarshalJSON(b []byte) error { + root := new(struct { + PermissionLevel WikiPagePermissionLevel `json:"permlevel"` + Listed bool `json:"listed"` + Things []thing `json:"editors"` + }) + + err := json.Unmarshal(b, root) + if err != nil { + return err + } + + s.PermissionLevel = root.PermissionLevel + s.Listed = root.Listed + + for _, thing := range root.Things { + if user, ok := thing.User(); ok { + s.Editors = append(s.Editors, user) + } + } + + return nil +} + +type wikiPageRevisionListing struct { + Data struct { + Revisions []*WikiPageRevision `json:"children"` + After string `json:"after"` + } `json:"data"` +} + +func (l *wikiPageRevisionListing) After() string { + return l.Data.After +} + +// WikiPageRevision is a revision of a wiki page. +type WikiPageRevision struct { + ID string `json:"id,omitempty"` + Page string `json:"page,omitempty"` + Created *Timestamp `json:"timestamp,omitempty"` + Reason string `json:"reason,omitempty"` + Hidden bool `json:"revision_hidden"` + Author *User `json:"author,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (r *WikiPageRevision) UnmarshalJSON(b []byte) error { + root := new(struct { + ID string `json:"id,omitempty"` + Page string `json:"page,omitempty"` + Created *Timestamp `json:"timestamp,omitempty"` + Reason string `json:"reason,omitempty"` + Hidden bool `json:"revision_hidden"` + Author thing `json:"author,omitempty"` + }) + + err := json.Unmarshal(b, root) + if err != nil { + return err + } + + r.ID = root.ID + r.Page = root.Page + r.Created = root.Created + r.Reason = root.Reason + r.Hidden = root.Hidden + + if user, ok := root.Author.User(); ok { + r.Author = user + } + + return nil +} + +// Page gets a wiki page. +func (s *WikiService) Page(ctx context.Context, subreddit, page string) (*WikiPage, *Response, error) { + return s.PageRevision(ctx, subreddit, page, "") +} + +// PageRevision gets a wiki page at the version it was at the revisionID provided. +// If revisionID is an empty string, it will get the most recent version. +func (s *WikiService) PageRevision(ctx context.Context, subreddit, page, revisionID string) (*WikiPage, *Response, error) { + path := fmt.Sprintf("r/%s/wiki/%s", subreddit, page) + + params := struct { + RevisionID string `url:"v,omitempty"` + }{revisionID} + + t, resp, err := s.client.getThing(ctx, path, params) + if err != nil { + return nil, resp, err + } + + wikiPage, _ := t.WikiPage() + return wikiPage, resp, nil +} + +// Pages gets a list of wiki pages in the subreddit. +// Returns 403 Forbidden if the wiki is disabled. +func (s *WikiService) Pages(ctx context.Context, subreddit string) ([]string, *Response, error) { + path := fmt.Sprintf("r/%s/wiki/pages", subreddit) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + wikiPages, _ := t.WikiPages() + return wikiPages, resp, nil +} + +// Edit a wiki page. +func (s *WikiService) Edit(ctx context.Context, editRequest *WikiPageEditRequest) (*Response, error) { + if editRequest == nil { + return nil, errors.New("*WikiPageEditRequest: cannot be nil") + } + + form, err := query.Values(editRequest) + if err != nil { + return nil, err + } + + path := fmt.Sprintf("r/%s/api/wiki/edit", editRequest.Subreddit) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Revert a wiki page to a specific revision. +func (s *WikiService) Revert(ctx context.Context, subreddit, page, revisionID string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/wiki/revert", subreddit) + + form := url.Values{} + form.Set("page", page) + form.Set("revision", revisionID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Settings gets the subreddit's wiki page's settings. +func (s *WikiService) Settings(ctx context.Context, subreddit, page string) (*WikiPageSettings, *Response, error) { + path := fmt.Sprintf("r/%s/wiki/settings/%s", subreddit, page) + t, resp, err := s.client.getThing(ctx, path, nil) + if err != nil { + return nil, resp, err + } + settings, _ := t.WikiPageSettings() + return settings, resp, nil +} + +// UpdateSettings updates the subreddit's wiki page's settings. +func (s *WikiService) UpdateSettings(ctx context.Context, subreddit, page string, updateRequest *WikiPageSettingsUpdateRequest) (*WikiPageSettings, *Response, error) { + if updateRequest == nil { + return nil, nil, errors.New("*WikiPageSettingsUpdateRequest: cannot be nil") + } + + form, err := query.Values(updateRequest) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("r/%s/wiki/settings/%s", subreddit, page) + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, nil, err + } + + root := new(thing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + settings, _ := root.WikiPageSettings() + return settings, resp, nil +} + +// Discussions gets a list of discussions (posts) about the wiki page. +func (s *WikiService) Discussions(ctx context.Context, subreddit, page string, opts *ListOptions) ([]*Post, *Response, error) { + path := fmt.Sprintf("r/%s/wiki/discussions/%s", subreddit, page) + l, resp, err := s.client.getListing(ctx, path, opts) + if err != nil { + return nil, resp, err + } + return l.Posts(), resp, nil +} + +// ToggleVisibility toggles the public visibility of a wiki page revision. +// The returned bool is whether the page was set to hidden or not. +func (s *WikiService) ToggleVisibility(ctx context.Context, subreddit, page, revisionID string) (bool, *Response, error) { + path := fmt.Sprintf("r/%s/api/wiki/hide", subreddit) + + form := url.Values{} + form.Set("page", page) + form.Set("revision", revisionID) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return false, nil, err + } + + root := new(struct { + Status bool `json:"status"` + }) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return false, resp, err + } + + return root.Status, resp, nil +} + +func (s *WikiService) revisions(ctx context.Context, subreddit, page string, opts *ListOptions) ([]*WikiPageRevision, *Response, error) { + path := fmt.Sprintf("r/%s/wiki/revisions", subreddit) + if page != "" { + path += "/" + page + } + + if opts != nil { + const idPrefix = "WikiRevision_" + if opts.After != "" && !strings.HasPrefix(opts.After, idPrefix) { + opts.After = idPrefix + opts.After + } + if opts.Before != "" && !strings.HasPrefix(opts.Before, idPrefix) { + opts.Before = idPrefix + opts.Before + } + } + + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(wikiPageRevisionListing) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Data.Revisions, resp, nil +} + +// Revisions gets revisions of all pages in the wiki. +func (s *WikiService) Revisions(ctx context.Context, subreddit string, opts *ListOptions) ([]*WikiPageRevision, *Response, error) { + return s.revisions(ctx, subreddit, "", opts) +} + +// RevisionsPage gets revisions of the specific wiki page. +// If page is an empty string, it gets revisions of all pages in the wiki. +func (s *WikiService) RevisionsPage(ctx context.Context, subreddit, page string, opts *ListOptions) ([]*WikiPageRevision, *Response, error) { + return s.revisions(ctx, subreddit, page, opts) +} + +// Allow the user to edit the specified wiki page in the subreddit. +func (s *WikiService) Allow(ctx context.Context, subreddit, page, username string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/wiki/alloweditor/add", subreddit) + + form := url.Values{} + form.Set("page", page) + form.Set("username", username) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} + +// Deny the user the ability to edit the specified wiki page in the subreddit. +func (s *WikiService) Deny(ctx context.Context, subreddit, page, username string) (*Response, error) { + path := fmt.Sprintf("r/%s/api/wiki/alloweditor/del", subreddit) + + form := url.Values{} + form.Set("page", page) + form.Set("username", username) + + req, err := s.client.NewRequest(http.MethodPost, path, form) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/v2/reddit/wiki_test.go b/v2/reddit/wiki_test.go new file mode 100644 index 0000000..cc9b961 --- /dev/null +++ b/v2/reddit/wiki_test.go @@ -0,0 +1,391 @@ +package reddit + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var expectedWikiPage = &WikiPage{ + Content: "test reason", + Reason: "this is a reason!", + MayRevise: true, + + RevisionID: "3c4e9fab-ef2c-11ea-90b6-0e9189256887", + RevisionDate: &Timestamp{time.Date(2020, 9, 5, 3, 59, 45, 0, time.UTC)}, + RevisionBy: &User{ + ID: "164ab8", + Name: "v_95", + Created: &Timestamp{time.Date(2017, 3, 12, 4, 56, 47, 0, time.UTC)}, + + PostKarma: 691, + CommentKarma: 22235, + + HasVerifiedEmail: true, + NSFW: true, + }, +} + +var expectedWikiPageSettings = &WikiPageSettings{ + PermissionLevel: PermissionSubredditWikiPermissions, + Listed: true, + Editors: []*User{ + { + ID: "164ab8", + Name: "v_95", + Created: &Timestamp{time.Date(2017, 3, 12, 4, 56, 47, 0, time.UTC)}, + + PostKarma: 691, + CommentKarma: 22235, + + HasVerifiedEmail: true, + NSFW: true, + }, + }, +} + +var expectedWikiPageDiscussions = []*Post{ + { + ID: "imj8g5", + FullID: "t3_imj8g5", + Created: &Timestamp{time.Date(2020, 9, 4, 16, 33, 33, 0, time.UTC)}, + Edited: &Timestamp{time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)}, + + Permalink: "/r/helloworldtestt/comments/imj8g5/test/", + URL: "https://www.reddit.com/r/helloworldtestt/wiki/index", + + Title: "test", + + Likes: Bool(true), + + Score: 1, + UpvoteRatio: 1, + NumberOfComments: 0, + + SubredditName: "helloworldtestt", + SubredditNamePrefixed: "r/helloworldtestt", + SubredditID: "t5_2uquw1", + SubredditSubscribers: 2, + + Author: "v_95", + AuthorID: "t2_164ab8", + }, +} + +var expectedWikiPageRevisions = []*WikiPageRevision{ + { + ID: "3b28c343-effb-11ea-859e-0efe313b2cd3", + Page: "index", + Created: &Timestamp{time.Date(2020, 9, 6, 4, 41, 29, 0, time.UTC)}, + Reason: "reverted back 1 day", + Hidden: false, + Author: &User{ + ID: "164ab8", + Name: "v_95", + Created: &Timestamp{time.Date(2017, 3, 12, 4, 56, 47, 0, time.UTC)}, + + PostKarma: 691, + CommentKarma: 22235, + + HasVerifiedEmail: true, + NSFW: true, + }, + }, + { + ID: "d217592d-effa-11ea-90af-0e913d9ded0b", + Page: "index", + Created: &Timestamp{time.Date(2020, 9, 6, 4, 38, 33, 0, time.UTC)}, + Reason: "reverted back 10 minutes", + Hidden: false, + Author: &User{ + ID: "164ab8", + Name: "v_95", + Created: &Timestamp{time.Date(2017, 3, 12, 4, 56, 47, 0, time.UTC)}, + + PostKarma: 691, + CommentKarma: 22235, + + HasVerifiedEmail: true, + NSFW: true, + }, + }, +} + +func TestWikiService_Page(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/wiki/page.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/wiki/testpage", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + wikiPage, _, err := client.Wiki.Page(ctx, "testsubreddit", "testpage") + require.NoError(t, err) + require.Equal(t, expectedWikiPage, wikiPage) +} + +func TestWikiService_PageRevision(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/wiki/page.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/wiki/testpage", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("v", "testrevision") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + wikiPage, _, err := client.Wiki.PageRevision(ctx, "testsubreddit", "testpage", "testrevision") + require.NoError(t, err) + require.Equal(t, expectedWikiPage, wikiPage) +} + +func TestWikiService_Pages(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/wiki/pages", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, `{ + "kind": "wikipagelisting", + "data": [ + "faq", + "index" + ] + }`) + }) + + wikiPages, _, err := client.Wiki.Pages(ctx, "testsubreddit") + require.NoError(t, err) + require.Equal(t, []string{"faq", "index"}, wikiPages) +} + +func TestWikiService_Edit(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/wiki/edit", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("page", "testpage") + form.Set("content", "testcontent") + form.Set("reason", "testreason") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Wiki.Edit(ctx, nil) + require.EqualError(t, err, "*WikiPageEditRequest: cannot be nil") + + _, err = client.Wiki.Edit(ctx, &WikiPageEditRequest{ + Subreddit: "testsubreddit", + Page: "testpage", + Content: "testcontent", + Reason: "testreason", + }) + require.NoError(t, err) +} + +func TestWikiService_Revert(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/wiki/revert", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("page", "testpage") + form.Set("revision", "testrevision") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Wiki.Revert(ctx, "testsubreddit", "testpage", "testrevision") + require.NoError(t, err) +} + +func TestWikiService_Settings(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/wiki/page-settings.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/wiki/settings/testpage", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + wikiPageSettings, _, err := client.Wiki.Settings(ctx, "testsubreddit", "testpage") + require.NoError(t, err) + require.Equal(t, expectedWikiPageSettings, wikiPageSettings) +} + +func TestWikiService_UpdateSettings(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/wiki/page-settings.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/wiki/settings/testpage", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("permlevel", "1") + form.Set("listed", "false") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, blob) + }) + + _, _, err = client.Wiki.UpdateSettings(ctx, "testsubreddit", "testpage", nil) + require.EqualError(t, err, "*WikiPageSettingsUpdateRequest: cannot be nil") + + wikiPageSettings, _, err := client.Wiki.UpdateSettings(ctx, "testsubreddit", "testpage", &WikiPageSettingsUpdateRequest{ + Listed: Bool(false), + PermissionLevel: PermissionApprovedContributorsOnly, + }) + require.NoError(t, err) + require.Equal(t, expectedWikiPageSettings, wikiPageSettings) +} + +func TestWikiService_Discussions(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/wiki/discussions.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/wiki/discussions/testpage", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + wikiPageDiscussions, _, err := client.Wiki.Discussions(ctx, "testsubreddit", "testpage", nil) + require.NoError(t, err) + require.Equal(t, expectedWikiPageDiscussions, wikiPageDiscussions) +} + +func TestWikiService_ToggleVisibility(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/wiki/hide", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("page", "testpage") + form.Set("revision", "testrevision") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + + fmt.Fprint(w, `{"status": true}`) + }) + + hidden, _, err := client.Wiki.ToggleVisibility(ctx, "testsubreddit", "testpage", "testrevision") + require.NoError(t, err) + require.True(t, hidden) +} + +func TestWikiService_Revisions(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/wiki/revisions.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/wiki/revisions", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + fmt.Fprint(w, blob) + }) + + wikiPageRevisions, _, err := client.Wiki.Revisions(ctx, "testsubreddit", nil) + require.NoError(t, err) + require.Equal(t, expectedWikiPageRevisions, wikiPageRevisions) +} + +func TestWikiService_RevisionsPage(t *testing.T) { + client, mux := setup(t) + + blob, err := readFileContents("../testdata/wiki/revisions.json") + require.NoError(t, err) + + mux.HandleFunc("/r/testsubreddit/wiki/revisions/testpage", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + + form := url.Values{} + form.Set("limit", "10") + form.Set("after", "WikiRevision_wikiId1") + form.Set("before", "WikiRevision_wikiId2") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.Form) + + fmt.Fprint(w, blob) + }) + + wikiPageRevisions, _, err := client.Wiki.RevisionsPage(ctx, "testsubreddit", "testpage", &ListOptions{ + Limit: 10, + After: "wikiId1", + Before: "wikiId2", + }) + require.NoError(t, err) + require.Equal(t, expectedWikiPageRevisions, wikiPageRevisions) +} + +func TestWikiService_Allow(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/wiki/alloweditor/add", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("page", "testpage") + form.Set("username", "testusername") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Wiki.Allow(ctx, "testsubreddit", "testpage", "testusername") + require.NoError(t, err) +} + +func TestWikiService_Deny(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/r/testsubreddit/api/wiki/alloweditor/del", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + form := url.Values{} + form.Set("page", "testpage") + form.Set("username", "testusername") + + err := r.ParseForm() + require.NoError(t, err) + require.Equal(t, form, r.PostForm) + }) + + _, err := client.Wiki.Deny(ctx, "testsubreddit", "testpage", "testusername") + require.NoError(t, err) +}