mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 14:12:26 -05: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:
		
					parent
					
						
							
								368c97f0f8
							
						
					
				
			
			
				commit
				
					
						a237e2b295
					
				
			
		
					 37 changed files with 2820 additions and 46 deletions
				
			
		|  | @ -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) | ||||
| 	} | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue