mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[feature] Implement following hashtags (#3141)
* Implement followed tags API * Insert statuses with followed tags into home timelines * Test following and unfollowing tags * Correct Swagger path params * Trim conversation caches * Migration for followed_tags table * Followed tag caches and DB implementation * Lint and tests * Add missing tag info endpoint, reorganize tag API * Unwrap boosts when timelining based on tags * Apply visibility filters to tag followers * Address review comments
This commit is contained in:
@ -32,6 +32,7 @@ import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"
|
||||
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
|
||||
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies"
|
||||
@ -46,6 +47,7 @@ import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/search"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/streaming"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/timelines"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
@ -59,7 +61,7 @@ type Client struct {
|
||||
processor *processing.Processor
|
||||
db db.DB
|
||||
|
||||
accounts *accounts.Module // api/v1/accounts
|
||||
accounts *accounts.Module // api/v1/accounts, api/v1/profile
|
||||
admin *admin.Module // api/v1/admin
|
||||
apps *apps.Module // api/v1/apps
|
||||
blocks *blocks.Module // api/v1/blocks
|
||||
@ -71,6 +73,7 @@ type Client struct {
|
||||
filtersV1 *filtersV1.Module // api/v1/filters
|
||||
filtersV2 *filtersV2.Module // api/v2/filters
|
||||
followRequests *followrequests.Module // api/v1/follow_requests
|
||||
followedTags *followedtags.Module // api/v1/followed_tags
|
||||
instance *instance.Module // api/v1/instance
|
||||
interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
|
||||
lists *lists.Module // api/v1/lists
|
||||
@ -84,6 +87,7 @@ type Client struct {
|
||||
search *search.Module // api/v1/search, api/v2/search
|
||||
statuses *statuses.Module // api/v1/statuses
|
||||
streaming *streaming.Module // api/v1/streaming
|
||||
tags *tags.Module // api/v1/tags
|
||||
timelines *timelines.Module // api/v1/timelines
|
||||
user *user.Module // api/v1/user
|
||||
}
|
||||
@ -117,6 +121,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
|
||||
c.filtersV1.Route(h)
|
||||
c.filtersV2.Route(h)
|
||||
c.followRequests.Route(h)
|
||||
c.followedTags.Route(h)
|
||||
c.instance.Route(h)
|
||||
c.interactionPolicies.Route(h)
|
||||
c.lists.Route(h)
|
||||
@ -130,6 +135,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
|
||||
c.search.Route(h)
|
||||
c.statuses.Route(h)
|
||||
c.streaming.Route(h)
|
||||
c.tags.Route(h)
|
||||
c.timelines.Route(h)
|
||||
c.user.Route(h)
|
||||
}
|
||||
@ -151,6 +157,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
|
||||
filtersV1: filtersV1.New(p),
|
||||
filtersV2: filtersV2.New(p),
|
||||
followRequests: followrequests.New(p),
|
||||
followedTags: followedtags.New(p),
|
||||
instance: instance.New(p),
|
||||
interactionPolicies: interactionpolicies.New(p),
|
||||
lists: lists.New(p),
|
||||
@ -164,6 +171,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
|
||||
search: search.New(p),
|
||||
statuses: statuses.New(p),
|
||||
streaming: streaming.New(p, time.Second*30, 4096),
|
||||
tags: tags.New(p),
|
||||
timelines: timelines.New(p),
|
||||
user: user.New(p),
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ import (
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - featured_tags
|
||||
// - tags
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
|
43
internal/api/client/followedtags/followedtags.go
Normal file
43
internal/api/client/followedtags/followedtags.go
Normal file
@ -0,0 +1,43 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package followedtags
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
)
|
||||
|
||||
const (
|
||||
BasePath = "/v1/followed_tags"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
processor *processing.Processor
|
||||
}
|
||||
|
||||
func New(processor *processing.Processor) *Module {
|
||||
return &Module{
|
||||
processor: processor,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
attachHandler(http.MethodGet, BasePath, m.FollowedTagsGETHandler)
|
||||
}
|
104
internal/api/client/followedtags/followedtags_test.go
Normal file
104
internal/api/client/followedtags/followedtags_test.go
Normal file
@ -0,0 +1,104 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package followedtags_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type FollowedTagsTestSuite struct {
|
||||
suite.Suite
|
||||
db db.DB
|
||||
storage *storage.Driver
|
||||
mediaManager *media.Manager
|
||||
federator *federation.Federator
|
||||
processor *processing.Processor
|
||||
emailSender email.Sender
|
||||
sentEmails map[string]string
|
||||
state state.State
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*gtsmodel.Token
|
||||
testClients map[string]*gtsmodel.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testTags map[string]*gtsmodel.Tag
|
||||
|
||||
// module being tested
|
||||
followedTagsModule *followedtags.Module
|
||||
}
|
||||
|
||||
func (suite *FollowedTagsTestSuite) SetupSuite() {
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testTags = testrig.NewTestTags()
|
||||
}
|
||||
|
||||
func (suite *FollowedTagsTestSuite) SetupTest() {
|
||||
suite.state.Caches.Init()
|
||||
testrig.StartNoopWorkers(&suite.state)
|
||||
|
||||
testrig.InitTestConfig()
|
||||
config.Config(func(cfg *config.Configuration) {
|
||||
cfg.WebAssetBaseDir = "../../../../web/assets/"
|
||||
cfg.WebTemplateBaseDir = "../../../../web/templates/"
|
||||
})
|
||||
testrig.InitTestLog()
|
||||
|
||||
suite.db = testrig.NewTestDB(&suite.state)
|
||||
suite.state.DB = suite.db
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
|
||||
suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
|
||||
suite.followedTagsModule = followedtags.New(suite.processor)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
}
|
||||
|
||||
func (suite *FollowedTagsTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
testrig.StopWorkers(&suite.state)
|
||||
}
|
||||
|
||||
func TestFollowedTagsTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(FollowedTagsTestSuite))
|
||||
}
|
139
internal/api/client/followedtags/get.go
Normal file
139
internal/api/client/followedtags/get.go
Normal file
@ -0,0 +1,139 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package followedtags
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// FollowedTagsGETHandler swagger:operation GET /api/v1/followed_tags getFollowedTags
|
||||
//
|
||||
// Get an array of all hashtags that you currently follow.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - tags
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:follows
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: max_id
|
||||
// type: string
|
||||
// description: >-
|
||||
// Return only followed tags *OLDER* than the given max ID.
|
||||
// The followed tag with the specified ID will not be included in the response.
|
||||
// NOTE: the ID is of the internal followed tag, NOT a tag name.
|
||||
// in: query
|
||||
// required: false
|
||||
// -
|
||||
// name: since_id
|
||||
// type: string
|
||||
// description: >-
|
||||
// Return only followed tags *NEWER* than the given since ID.
|
||||
// The followed tag with the specified ID will not be included in the response.
|
||||
// NOTE: the ID is of the internal followed tag, NOT a tag name.
|
||||
// in: query
|
||||
// -
|
||||
// name: min_id
|
||||
// type: string
|
||||
// description: >-
|
||||
// Return only followed tags *IMMEDIATELY NEWER* than the given min ID.
|
||||
// The followed tag with the specified ID will not be included in the response.
|
||||
// NOTE: the ID is of the internal followed tag, NOT a tag name.
|
||||
// in: query
|
||||
// required: false
|
||||
// -
|
||||
// name: limit
|
||||
// type: integer
|
||||
// description: Number of followed tags to return.
|
||||
// default: 100
|
||||
// minimum: 1
|
||||
// maximum: 200
|
||||
// in: query
|
||||
// required: false
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// headers:
|
||||
// Link:
|
||||
// type: string
|
||||
// description: Links to the next and previous queries.
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/tag"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) FollowedTagsGETHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
page, errWithCode := paging.ParseIDPage(c,
|
||||
1, // min limit
|
||||
200, // max limit
|
||||
100, // default limit
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
resp, errWithCode := m.processor.Tags().Followed(
|
||||
c.Request.Context(),
|
||||
authed.Account.ID,
|
||||
page,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.LinkHeader != "" {
|
||||
c.Header("Link", resp.LinkHeader)
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, resp.Items)
|
||||
}
|
125
internal/api/client/followedtags/get_test.go
Normal file
125
internal/api/client/followedtags/get_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package followedtags_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
func (suite *FollowedTagsTestSuite) getFollowedTags(
|
||||
accountFixtureName string,
|
||||
expectedHTTPStatus int,
|
||||
expectedBody string,
|
||||
) ([]apimodel.Tag, error) {
|
||||
// instantiate recorder + test context
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[accountFixtureName]))
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
|
||||
|
||||
// create the request
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+followedtags.BasePath, nil)
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
// trigger the handler
|
||||
suite.followedTagsModule.FollowedTagsGETHandler(ctx)
|
||||
|
||||
// read the response
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
b, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
errs := gtserror.NewMultiError(2)
|
||||
|
||||
// check code + body
|
||||
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
|
||||
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
|
||||
if expectedBody == "" {
|
||||
return nil, errs.Combine()
|
||||
}
|
||||
}
|
||||
|
||||
// if we got an expected body, return early
|
||||
if expectedBody != "" {
|
||||
if string(b) != expectedBody {
|
||||
errs.Appendf("expected %s got %s", expectedBody, string(b))
|
||||
}
|
||||
return nil, errs.Combine()
|
||||
}
|
||||
|
||||
resp := []apimodel.Tag{}
|
||||
if err := json.Unmarshal(b, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Test that we can list a user's followed tags.
|
||||
func (suite *FollowedTagsTestSuite) TestGet() {
|
||||
accountFixtureName := "local_account_2"
|
||||
testAccount := suite.testAccounts[accountFixtureName]
|
||||
testTag := suite.testTags["welcome"]
|
||||
|
||||
// Follow an existing tag.
|
||||
if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
followedTags, err := suite.getFollowedTags(accountFixtureName, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
if suite.Len(followedTags, 1) {
|
||||
followedTag := followedTags[0]
|
||||
suite.Equal(testTag.Name, followedTag.Name)
|
||||
if suite.NotNil(followedTag.Following) {
|
||||
suite.True(*followedTag.Following)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test that we can list a user's followed tags even if they don't have any.
|
||||
func (suite *FollowedTagsTestSuite) TestGetEmpty() {
|
||||
accountFixtureName := "local_account_1"
|
||||
|
||||
followedTags, err := suite.getFollowedTags(accountFixtureName, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Len(followedTags, 0)
|
||||
}
|
92
internal/api/client/tags/follow.go
Normal file
92
internal/api/client/tags/follow.go
Normal file
@ -0,0 +1,92 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package tags
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// FollowTagPOSTHandler swagger:operation POST /api/v1/tags/{tag_name}/follow followTag
|
||||
//
|
||||
// Follow a hashtag.
|
||||
//
|
||||
// Idempotent: if you are already following the tag, this call will still succeed.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - tags
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:follows
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: tag_name
|
||||
// type: string
|
||||
// description: Name of the tag (no leading `#`)
|
||||
// in: path
|
||||
// required: true
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: "Info about the tag."
|
||||
// schema:
|
||||
// "$ref": "#/definitions/tag"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) FollowTagPOSTHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if authed.Account.IsMoving() {
|
||||
apiutil.ForbiddenAfterMove(c)
|
||||
return
|
||||
}
|
||||
|
||||
name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiTag, errWithCode := m.processor.Tags().Follow(c.Request.Context(), authed.Account, name)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, apiTag)
|
||||
}
|
82
internal/api/client/tags/follow_test.go
Normal file
82
internal/api/client/tags/follow_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package tags_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
)
|
||||
|
||||
func (suite *TagsTestSuite) follow(
|
||||
accountFixtureName string,
|
||||
tagName string,
|
||||
expectedHTTPStatus int,
|
||||
expectedBody string,
|
||||
) (*apimodel.Tag, error) {
|
||||
return suite.tagAction(
|
||||
accountFixtureName,
|
||||
tagName,
|
||||
http.MethodPost,
|
||||
tags.FollowPath,
|
||||
suite.tagsModule.FollowTagPOSTHandler,
|
||||
expectedHTTPStatus,
|
||||
expectedBody,
|
||||
)
|
||||
}
|
||||
|
||||
// Follow a tag we don't already follow.
|
||||
func (suite *TagsTestSuite) TestFollow() {
|
||||
accountFixtureName := "local_account_2"
|
||||
testTag := suite.testTags["welcome"]
|
||||
|
||||
apiTag, err := suite.follow(accountFixtureName, testTag.Name, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(testTag.Name, apiTag.Name)
|
||||
if suite.NotNil(apiTag.Following) {
|
||||
suite.True(*apiTag.Following)
|
||||
}
|
||||
}
|
||||
|
||||
// When we follow a tag already followed by the account, it should succeed.
|
||||
func (suite *TagsTestSuite) TestFollowIdempotent() {
|
||||
accountFixtureName := "local_account_2"
|
||||
testAccount := suite.testAccounts[accountFixtureName]
|
||||
testTag := suite.testTags["welcome"]
|
||||
|
||||
// Setup: follow an existing tag.
|
||||
if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Follow it again through the API.
|
||||
apiTag, err := suite.follow(accountFixtureName, testTag.Name, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(testTag.Name, apiTag.Name)
|
||||
if suite.NotNil(apiTag.Following) {
|
||||
suite.True(*apiTag.Following)
|
||||
}
|
||||
}
|
89
internal/api/client/tags/get.go
Normal file
89
internal/api/client/tags/get.go
Normal file
@ -0,0 +1,89 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package tags
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// TagGETHandler swagger:operation GET /api/v1/tags/{tag_name} getTag
|
||||
//
|
||||
// Get details for a hashtag, including whether you currently follow it.
|
||||
//
|
||||
// If the tag does not exist, this method will not create it in the database.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - tags
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:follows
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: tag_name
|
||||
// type: string
|
||||
// description: Name of the tag (no leading `#`)
|
||||
// in: path
|
||||
// required: true
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: "Info about the tag."
|
||||
// schema:
|
||||
// "$ref": "#/definitions/tag"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) TagGETHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiTag, errWithCode := m.processor.Tags().Get(c.Request.Context(), authed.Account, name)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, apiTag)
|
||||
}
|
93
internal/api/client/tags/get_test.go
Normal file
93
internal/api/client/tags/get_test.go
Normal file
@ -0,0 +1,93 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package tags_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
)
|
||||
|
||||
// tagAction follows or unfollows a tag.
|
||||
func (suite *TagsTestSuite) get(
|
||||
accountFixtureName string,
|
||||
tagName string,
|
||||
expectedHTTPStatus int,
|
||||
expectedBody string,
|
||||
) (*apimodel.Tag, error) {
|
||||
return suite.tagAction(
|
||||
accountFixtureName,
|
||||
tagName,
|
||||
http.MethodGet,
|
||||
tags.TagPath,
|
||||
suite.tagsModule.TagGETHandler,
|
||||
expectedHTTPStatus,
|
||||
expectedBody,
|
||||
)
|
||||
}
|
||||
|
||||
// Get a tag followed by the account.
|
||||
func (suite *TagsTestSuite) TestGetFollowed() {
|
||||
accountFixtureName := "local_account_2"
|
||||
testAccount := suite.testAccounts[accountFixtureName]
|
||||
testTag := suite.testTags["welcome"]
|
||||
|
||||
// Setup: follow an existing tag.
|
||||
if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Get it through the API.
|
||||
apiTag, err := suite.get(accountFixtureName, testTag.Name, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(testTag.Name, apiTag.Name)
|
||||
if suite.NotNil(apiTag.Following) {
|
||||
suite.True(*apiTag.Following)
|
||||
}
|
||||
}
|
||||
|
||||
// Get a tag not followed by the account.
|
||||
func (suite *TagsTestSuite) TestGetUnfollowed() {
|
||||
accountFixtureName := "local_account_2"
|
||||
testTag := suite.testTags["Hashtag"]
|
||||
|
||||
apiTag, err := suite.get(accountFixtureName, testTag.Name, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(testTag.Name, apiTag.Name)
|
||||
if suite.NotNil(apiTag.Following) {
|
||||
suite.False(*apiTag.Following)
|
||||
}
|
||||
}
|
||||
|
||||
// Get a tag that does not exist, which should result in a 404.
|
||||
func (suite *TagsTestSuite) TestGetNotFound() {
|
||||
accountFixtureName := "local_account_2"
|
||||
|
||||
_, err := suite.get(accountFixtureName, "THIS_TAG_DOES_NOT_EXIST", http.StatusNotFound, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
}
|
49
internal/api/client/tags/tags.go
Normal file
49
internal/api/client/tags/tags.go
Normal file
@ -0,0 +1,49 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package tags
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
)
|
||||
|
||||
const (
|
||||
BasePath = "/v1/tags"
|
||||
TagPath = BasePath + "/:" + apiutil.TagNameKey
|
||||
FollowPath = TagPath + "/follow"
|
||||
UnfollowPath = TagPath + "/unfollow"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
processor *processing.Processor
|
||||
}
|
||||
|
||||
func New(processor *processing.Processor) *Module {
|
||||
return &Module{
|
||||
processor: processor,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
attachHandler(http.MethodGet, TagPath, m.TagGETHandler)
|
||||
attachHandler(http.MethodPost, FollowPath, m.FollowTagPOSTHandler)
|
||||
attachHandler(http.MethodPost, UnfollowPath, m.UnfollowTagPOSTHandler)
|
||||
}
|
179
internal/api/client/tags/tags_test.go
Normal file
179
internal/api/client/tags/tags_test.go
Normal file
@ -0,0 +1,179 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package tags_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type TagsTestSuite struct {
|
||||
suite.Suite
|
||||
db db.DB
|
||||
storage *storage.Driver
|
||||
mediaManager *media.Manager
|
||||
federator *federation.Federator
|
||||
processor *processing.Processor
|
||||
emailSender email.Sender
|
||||
sentEmails map[string]string
|
||||
state state.State
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*gtsmodel.Token
|
||||
testClients map[string]*gtsmodel.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testTags map[string]*gtsmodel.Tag
|
||||
|
||||
// module being tested
|
||||
tagsModule *tags.Module
|
||||
}
|
||||
|
||||
func (suite *TagsTestSuite) SetupSuite() {
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testTags = testrig.NewTestTags()
|
||||
}
|
||||
|
||||
func (suite *TagsTestSuite) SetupTest() {
|
||||
suite.state.Caches.Init()
|
||||
testrig.StartNoopWorkers(&suite.state)
|
||||
|
||||
testrig.InitTestConfig()
|
||||
config.Config(func(cfg *config.Configuration) {
|
||||
cfg.WebAssetBaseDir = "../../../../web/assets/"
|
||||
cfg.WebTemplateBaseDir = "../../../../web/templates/"
|
||||
})
|
||||
testrig.InitTestLog()
|
||||
|
||||
suite.db = testrig.NewTestDB(&suite.state)
|
||||
suite.state.DB = suite.db
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
|
||||
suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
|
||||
suite.tagsModule = tags.New(suite.processor)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
}
|
||||
|
||||
func (suite *TagsTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
testrig.StopWorkers(&suite.state)
|
||||
}
|
||||
|
||||
// tagAction gets, follows, or unfollows a tag, returning the tag.
|
||||
func (suite *TagsTestSuite) tagAction(
|
||||
accountFixtureName string,
|
||||
tagName string,
|
||||
method string,
|
||||
path string,
|
||||
handler func(c *gin.Context),
|
||||
expectedHTTPStatus int,
|
||||
expectedBody string,
|
||||
) (*apimodel.Tag, error) {
|
||||
// instantiate recorder + test context
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[accountFixtureName]))
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
|
||||
|
||||
// create the request
|
||||
url := config.GetProtocol() + "://" + config.GetHost() + "/api/" + path
|
||||
ctx.Request = httptest.NewRequest(
|
||||
method,
|
||||
strings.Replace(url, ":tag_name", tagName, 1),
|
||||
nil,
|
||||
)
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
ctx.AddParam("tag_name", tagName)
|
||||
|
||||
// trigger the handler
|
||||
handler(ctx)
|
||||
|
||||
// read the response
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
b, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
errs := gtserror.NewMultiError(2)
|
||||
|
||||
// check code + body
|
||||
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
|
||||
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
|
||||
if expectedBody == "" {
|
||||
return nil, errs.Combine()
|
||||
}
|
||||
}
|
||||
|
||||
// if we got an expected body, return early
|
||||
if expectedBody != "" {
|
||||
if string(b) != expectedBody {
|
||||
errs.Appendf("expected %s got %s", expectedBody, string(b))
|
||||
}
|
||||
return nil, errs.Combine()
|
||||
}
|
||||
|
||||
resp := &apimodel.Tag{}
|
||||
if err := json.Unmarshal(b, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func TestTagsTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(TagsTestSuite))
|
||||
}
|
94
internal/api/client/tags/unfollow.go
Normal file
94
internal/api/client/tags/unfollow.go
Normal file
@ -0,0 +1,94 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package tags
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// UnfollowTagPOSTHandler swagger:operation POST /api/v1/tags/{tag_name}/unfollow unfollowTag
|
||||
//
|
||||
// Unfollow a hashtag.
|
||||
//
|
||||
// Idempotent: if you are not following the tag, this call will still succeed.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - tags
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:follows
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: tag_name
|
||||
// type: string
|
||||
// description: Name of the tag (no leading `#`)
|
||||
// in: path
|
||||
// required: true
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: "Info about the tag."
|
||||
// schema:
|
||||
// "$ref": "#/definitions/tag"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '404':
|
||||
// description: unauthorized
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) UnfollowTagPOSTHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if authed.Account.IsMoving() {
|
||||
apiutil.ForbiddenAfterMove(c)
|
||||
return
|
||||
}
|
||||
|
||||
name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiTag, errWithCode := m.processor.Tags().Unfollow(c.Request.Context(), authed.Account, name)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, apiTag)
|
||||
}
|
82
internal/api/client/tags/unfollow_test.go
Normal file
82
internal/api/client/tags/unfollow_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package tags_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
)
|
||||
|
||||
func (suite *TagsTestSuite) unfollow(
|
||||
accountFixtureName string,
|
||||
tagName string,
|
||||
expectedHTTPStatus int,
|
||||
expectedBody string,
|
||||
) (*apimodel.Tag, error) {
|
||||
return suite.tagAction(
|
||||
accountFixtureName,
|
||||
tagName,
|
||||
http.MethodPost,
|
||||
tags.UnfollowPath,
|
||||
suite.tagsModule.UnfollowTagPOSTHandler,
|
||||
expectedHTTPStatus,
|
||||
expectedBody,
|
||||
)
|
||||
}
|
||||
|
||||
// Unfollow a tag that we follow.
|
||||
func (suite *TagsTestSuite) TestUnfollow() {
|
||||
accountFixtureName := "local_account_2"
|
||||
testAccount := suite.testAccounts[accountFixtureName]
|
||||
testTag := suite.testTags["welcome"]
|
||||
|
||||
// Setup: follow an existing tag.
|
||||
if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Unfollow it through the API.
|
||||
apiTag, err := suite.unfollow(accountFixtureName, testTag.Name, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(testTag.Name, apiTag.Name)
|
||||
if suite.NotNil(apiTag.Following) {
|
||||
suite.False(*apiTag.Following)
|
||||
}
|
||||
}
|
||||
|
||||
// When we unfollow a tag not followed by the account, it should succeed.
|
||||
func (suite *TagsTestSuite) TestUnfollowIdempotent() {
|
||||
accountFixtureName := "local_account_2"
|
||||
testTag := suite.testTags["Hashtag"]
|
||||
|
||||
apiTag, err := suite.unfollow(accountFixtureName, testTag.Name, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(testTag.Name, apiTag.Name)
|
||||
if suite.NotNil(apiTag.Following) {
|
||||
suite.False(*apiTag.Following)
|
||||
}
|
||||
}
|
@ -31,4 +31,7 @@ type Tag struct {
|
||||
// Currently just a stub, if provided will always be an empty array.
|
||||
// example: []
|
||||
History *[]any `json:"history,omitempty"`
|
||||
// Following is true if the user is following this tag, false if they're not,
|
||||
// and not present if there is no currently authenticated user.
|
||||
Following *bool `json:"following,omitempty"`
|
||||
}
|
||||
|
Reference in New Issue
Block a user