diff options
Diffstat (limited to 'internal/api/client')
| -rw-r--r-- | internal/api/client/featuredtags/get.go | 2 | ||||
| -rw-r--r-- | internal/api/client/followedtags/followedtags.go | 43 | ||||
| -rw-r--r-- | internal/api/client/followedtags/followedtags_test.go | 104 | ||||
| -rw-r--r-- | internal/api/client/followedtags/get.go | 139 | ||||
| -rw-r--r-- | internal/api/client/followedtags/get_test.go | 125 | ||||
| -rw-r--r-- | internal/api/client/tags/follow.go | 92 | ||||
| -rw-r--r-- | internal/api/client/tags/follow_test.go | 82 | ||||
| -rw-r--r-- | internal/api/client/tags/get.go | 89 | ||||
| -rw-r--r-- | internal/api/client/tags/get_test.go | 93 | ||||
| -rw-r--r-- | internal/api/client/tags/tags.go | 49 | ||||
| -rw-r--r-- | internal/api/client/tags/tags_test.go | 179 | ||||
| -rw-r--r-- | internal/api/client/tags/unfollow.go | 94 | ||||
| -rw-r--r-- | internal/api/client/tags/unfollow_test.go | 82 | 
13 files changed, 1172 insertions, 1 deletions
| diff --git a/internal/api/client/featuredtags/get.go b/internal/api/client/featuredtags/get.go index c1ee7ca2c..de47f7ee2 100644 --- a/internal/api/client/featuredtags/get.go +++ b/internal/api/client/featuredtags/get.go @@ -34,7 +34,7 @@ import (  //  //	---  //	tags: -//	- featured_tags +//	- tags  //  //	produces:  //	- application/json diff --git a/internal/api/client/followedtags/followedtags.go b/internal/api/client/followedtags/followedtags.go new file mode 100644 index 000000000..27fca918d --- /dev/null +++ b/internal/api/client/followedtags/followedtags.go @@ -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) +} diff --git a/internal/api/client/followedtags/followedtags_test.go b/internal/api/client/followedtags/followedtags_test.go new file mode 100644 index 000000000..883ab033b --- /dev/null +++ b/internal/api/client/followedtags/followedtags_test.go @@ -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)) +} diff --git a/internal/api/client/followedtags/get.go b/internal/api/client/followedtags/get.go new file mode 100644 index 000000000..68e4ffb5f --- /dev/null +++ b/internal/api/client/followedtags/get.go @@ -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) +} diff --git a/internal/api/client/followedtags/get_test.go b/internal/api/client/followedtags/get_test.go new file mode 100644 index 000000000..bd82c7037 --- /dev/null +++ b/internal/api/client/followedtags/get_test.go @@ -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) +} diff --git a/internal/api/client/tags/follow.go b/internal/api/client/tags/follow.go new file mode 100644 index 000000000..2952996b1 --- /dev/null +++ b/internal/api/client/tags/follow.go @@ -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) +} diff --git a/internal/api/client/tags/follow_test.go b/internal/api/client/tags/follow_test.go new file mode 100644 index 000000000..d3b08cf5c --- /dev/null +++ b/internal/api/client/tags/follow_test.go @@ -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) +	} +} diff --git a/internal/api/client/tags/get.go b/internal/api/client/tags/get.go new file mode 100644 index 000000000..b61b7cc65 --- /dev/null +++ b/internal/api/client/tags/get.go @@ -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) +} diff --git a/internal/api/client/tags/get_test.go b/internal/api/client/tags/get_test.go new file mode 100644 index 000000000..fa31bce7d --- /dev/null +++ b/internal/api/client/tags/get_test.go @@ -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()) +	} +} diff --git a/internal/api/client/tags/tags.go b/internal/api/client/tags/tags.go new file mode 100644 index 000000000..281859547 --- /dev/null +++ b/internal/api/client/tags/tags.go @@ -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) +} diff --git a/internal/api/client/tags/tags_test.go b/internal/api/client/tags/tags_test.go new file mode 100644 index 000000000..79c708b10 --- /dev/null +++ b/internal/api/client/tags/tags_test.go @@ -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)) +} diff --git a/internal/api/client/tags/unfollow.go b/internal/api/client/tags/unfollow.go new file mode 100644 index 000000000..3166e08ed --- /dev/null +++ b/internal/api/client/tags/unfollow.go @@ -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) +} diff --git a/internal/api/client/tags/unfollow_test.go b/internal/api/client/tags/unfollow_test.go new file mode 100644 index 000000000..51bc34797 --- /dev/null +++ b/internal/api/client/tags/unfollow_test.go @@ -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) +	} +} | 
