summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorLibravatar Vyr Cossont <VyrCossont@users.noreply.github.com>2024-07-29 11:26:31 -0700
committerLibravatar GitHub <noreply@github.com>2024-07-29 19:26:31 +0100
commita237e2b295fee71bdf7266520b0b6e0fb79b565c (patch)
treec522adc47019584b60de9420595505820635bb11 /internal/api
parent[bugfix] take into account rotation when generating thumbnail (#3147) (diff)
downloadgotosocial-a237e2b295fee71bdf7266520b0b6e0fb79b565c.tar.xz
[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
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/client.go10
-rw-r--r--internal/api/client/featuredtags/get.go2
-rw-r--r--internal/api/client/followedtags/followedtags.go43
-rw-r--r--internal/api/client/followedtags/followedtags_test.go104
-rw-r--r--internal/api/client/followedtags/get.go139
-rw-r--r--internal/api/client/followedtags/get_test.go125
-rw-r--r--internal/api/client/tags/follow.go92
-rw-r--r--internal/api/client/tags/follow_test.go82
-rw-r--r--internal/api/client/tags/get.go89
-rw-r--r--internal/api/client/tags/get_test.go93
-rw-r--r--internal/api/client/tags/tags.go49
-rw-r--r--internal/api/client/tags/tags_test.go179
-rw-r--r--internal/api/client/tags/unfollow.go94
-rw-r--r--internal/api/client/tags/unfollow_test.go82
-rw-r--r--internal/api/model/tag.go3
15 files changed, 1184 insertions, 2 deletions
diff --git a/internal/api/client.go b/internal/api/client.go
index b8b226804..18ab9b50b 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -32,6 +32,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
"github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies"
@@ -46,6 +47,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/search"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
"github.com/superseriousbusiness/gotosocial/internal/api/client/streaming"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
"github.com/superseriousbusiness/gotosocial/internal/api/client/timelines"
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
"github.com/superseriousbusiness/gotosocial/internal/db"
@@ -59,7 +61,7 @@ type Client struct {
processor *processing.Processor
db db.DB
- accounts *accounts.Module // api/v1/accounts
+ accounts *accounts.Module // api/v1/accounts, api/v1/profile
admin *admin.Module // api/v1/admin
apps *apps.Module // api/v1/apps
blocks *blocks.Module // api/v1/blocks
@@ -71,6 +73,7 @@ type Client struct {
filtersV1 *filtersV1.Module // api/v1/filters
filtersV2 *filtersV2.Module // api/v2/filters
followRequests *followrequests.Module // api/v1/follow_requests
+ followedTags *followedtags.Module // api/v1/followed_tags
instance *instance.Module // api/v1/instance
interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
lists *lists.Module // api/v1/lists
@@ -84,6 +87,7 @@ type Client struct {
search *search.Module // api/v1/search, api/v2/search
statuses *statuses.Module // api/v1/statuses
streaming *streaming.Module // api/v1/streaming
+ tags *tags.Module // api/v1/tags
timelines *timelines.Module // api/v1/timelines
user *user.Module // api/v1/user
}
@@ -117,6 +121,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.filtersV1.Route(h)
c.filtersV2.Route(h)
c.followRequests.Route(h)
+ c.followedTags.Route(h)
c.instance.Route(h)
c.interactionPolicies.Route(h)
c.lists.Route(h)
@@ -130,6 +135,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.search.Route(h)
c.statuses.Route(h)
c.streaming.Route(h)
+ c.tags.Route(h)
c.timelines.Route(h)
c.user.Route(h)
}
@@ -151,6 +157,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
filtersV1: filtersV1.New(p),
filtersV2: filtersV2.New(p),
followRequests: followrequests.New(p),
+ followedTags: followedtags.New(p),
instance: instance.New(p),
interactionPolicies: interactionpolicies.New(p),
lists: lists.New(p),
@@ -164,6 +171,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
search: search.New(p),
statuses: statuses.New(p),
streaming: streaming.New(p, time.Second*30, 4096),
+ tags: tags.New(p),
timelines: timelines.New(p),
user: user.New(p),
}
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)
+ }
+}
diff --git a/internal/api/model/tag.go b/internal/api/model/tag.go
index ebc12e2d4..4f81bac6f 100644
--- a/internal/api/model/tag.go
+++ b/internal/api/model/tag.go
@@ -31,4 +31,7 @@ type Tag struct {
// Currently just a stub, if provided will always be an empty array.
// example: []
History *[]any `json:"history,omitempty"`
+ // Following is true if the user is following this tag, false if they're not,
+ // and not present if there is no currently authenticated user.
+ Following *bool `json:"following,omitempty"`
}