[feature] Allow users to create + delete bookbarks, and view bookmarked statuses (#1168)

* Implement Bookmarks

* Update based on review comments

* Update swagger doc

* Fix argument passing to status.Bookmark

* Update changed test

* Updates based on latest PR review
This commit is contained in:
Matthew Phillips 2022-12-09 05:37:12 -05:00 committed by GitHub
parent e58d2d8122
commit 477ae50933
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1230 additions and 5 deletions

View file

@ -152,8 +152,8 @@ Crossed out - will not be implemented / will be stubbed only.
- [ ] /api/v1/statuses/:id/unmute POST (Unmute notifications on a status) - [ ] /api/v1/statuses/:id/unmute POST (Unmute notifications on a status)
- [ ] /api/v1/statuses/:id/pin POST (Pin a status to profile) - [ ] /api/v1/statuses/:id/pin POST (Pin a status to profile)
- [ ] /api/v1/statuses/:id/unpin POST (Unpin a status from profile) - [ ] /api/v1/statuses/:id/unpin POST (Unpin a status from profile)
- ~~/api/v1/statuses/:id/bookmark POST (Bookmark a status)~~ - [x] /api/v1/statuses/:id/bookmark POST (Bookmark a status)
- ~~/api/v1/statuses/:id/unbookmark POST (Undo a bookmark)~~ - [x] /api/v1/statuses/:id/unbookmark POST (Undo a bookmark)
- [x] Media - [x] Media
- [x] /api/v1/media POST (Upload a media attachment) - [x] /api/v1/media POST (Upload a media attachment)
- [x] /api/v1/media/:id GET (Get a media attachment) - [x] /api/v1/media/:id GET (Get a media attachment)
@ -242,8 +242,8 @@ Crossed out - will not be implemented / will be stubbed only.
- ~~Suggestions~~ - ~~Suggestions~~
- ~~/api/v1/suggestions GET (Get suggested accounts to follow)~~ - ~~/api/v1/suggestions GET (Get suggested accounts to follow)~~
- ~~/api/v1/suggestions/:account_id DELETE (Delete a suggestion)~~ - ~~/api/v1/suggestions/:account_id DELETE (Delete a suggestion)~~
- ~~Bookmarks~~ - [x] Bookmarks
- ~~/api/v1/bookmarks GET (See bookmarked statuses)~~ - [x] /api/v1/bookmarks GET (See bookmarked statuses)
### Non-API tasks ### Non-API tasks

View file

@ -3326,6 +3326,34 @@ paths:
summary: Get an array of accounts that requesting account has blocked. summary: Get an array of accounts that requesting account has blocked.
tags: tags:
- blocks - blocks
/api/v1/bookmarks:
get:
description: Get an array of statuses bookmarked in the instance
operationId: bookmarksGet
produces:
- application/json
responses:
"200":
description: Array of bookmarked statuses
headers:
Link:
description: Links to the next and previous queries.
type: string
schema:
items:
$ref: '#/definitions/status'
type: array
"401":
description: unauthorized
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- read:bookmarks
tags:
- bookmarks
/api/v1/custom_emojis: /api/v1/custom_emojis:
get: get:
operationId: customEmojisGet operationId: customEmojisGet
@ -4111,6 +4139,40 @@ paths:
summary: View status with the given ID. summary: View status with the given ID.
tags: tags:
- statuses - statuses
/api/v1/statuses/{id}/bookmark:
post:
operationId: statusBookmark
parameters:
- description: Target status ID.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: The status.
schema:
$ref: '#/definitions/status'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:statuses
summary: Bookmark status with the given ID.
tags:
- statuses
/api/v1/statuses/{id}/context: /api/v1/statuses/{id}/context:
get: get:
description: The returned statuses will be ordered in a thread structure, so they are suitable to be displayed in the order in which they were returned. description: The returned statuses will be ordered in a thread structure, so they are suitable to be displayed in the order in which they were returned.
@ -4285,6 +4347,40 @@ paths:
summary: View accounts that have reblogged/boosted the target status. summary: View accounts that have reblogged/boosted the target status.
tags: tags:
- statuses - statuses
/api/v1/statuses/{id}/unbookmark:
post:
operationId: statusUnbookmark
parameters:
- description: Target status ID.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: The status.
schema:
$ref: '#/definitions/status'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:statuses
summary: Unbookmark status with the given ID.
tags:
- statuses
/api/v1/statuses/{id}/unfavourite: /api/v1/statuses/{id}/unfavourite:
post: post:
operationId: statusUnfave operationId: statusUnfave

View file

@ -0,0 +1,50 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 bookmarks
import (
"net/http"
"github.com/superseriousbusiness/gotosocial/internal/api"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
const (
// BasePath is the base path for serving the bookmarks API
BasePath = "/api/v1/bookmarks"
)
// Module implements the ClientAPIModule interface for everything related to bookmarks
type Module struct {
processor processing.Processor
}
// New returns a new emoji module
func New(processor processing.Processor) api.ClientModule {
return &Module{
processor: processor,
}
}
// Route attaches all routes from this module to the given router
func (m *Module) Route(r router.Router) error {
r.AttachHandler(http.MethodGet, BasePath, m.BookmarksGETHandler)
return nil
}

View file

@ -0,0 +1,148 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 bookmarks_test
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/bookmarks"
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
"github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/concurrency"
"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/messages"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type BookmarkTestSuite struct {
// standard suite interfaces
suite.Suite
db db.DB
tc typeutils.TypeConverter
mediaManager media.Manager
federator federation.Federator
emailSender email.Sender
processor processing.Processor
storage *storage.Driver
// 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
testAttachments map[string]*gtsmodel.MediaAttachment
testStatuses map[string]*gtsmodel.Status
testFollows map[string]*gtsmodel.Follow
// module being tested
statusModule *status.Module
bookmarkModule *bookmarks.Module
}
func (suite *BookmarkTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens()
suite.testClients = testrig.NewTestClients()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
suite.testAttachments = testrig.NewTestAttachments()
suite.testStatuses = testrig.NewTestStatuses()
suite.testFollows = testrig.NewTestFollows()
}
func (suite *BookmarkTestSuite) SetupTest() {
testrig.InitTestConfig()
testrig.InitTestLog()
suite.db = testrig.NewTestDB()
suite.tc = testrig.NewTestTypeConverter(suite.db)
suite.storage = testrig.NewInMemoryStorage()
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1)
clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1)
suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage)
suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker)
suite.statusModule = status.New(suite.processor).(*status.Module)
suite.bookmarkModule = bookmarks.New(suite.processor).(*bookmarks.Module)
suite.NoError(suite.processor.Start())
}
func (suite *BookmarkTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
testrig.StandardStorageTeardown(suite.storage)
}
func (suite *BookmarkTestSuite) TestGetBookmark() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.DBTokenToToken(t)
targetStatus := suite.testStatuses["admin_account_status_1"]
// setup
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
ctx.Request.Header.Set("accept", "application/json")
suite.bookmarkModule.BookmarksGETHandler(ctx)
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
suite.NoError(err)
var statuses []model.Status
err = json.Unmarshal(b, &statuses)
suite.NoError(err)
suite.Equal(1, len(statuses))
}
func TestBookmarkTestSuite(t *testing.T) {
suite.Run(t, new(BookmarkTestSuite))
}

View file

@ -0,0 +1,107 @@
package bookmarks
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/api"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
const (
// LimitKey is for setting the return amount limit for eg., requesting an account's statuses
LimitKey = "limit"
// MaxIDKey is for specifying the maximum ID of the bookmark to retrieve.
MaxIDKey = "max_id"
// MinIDKey is for specifying the minimum ID of the bookmark to retrieve.
MinIDKey = "min_id"
)
// BookmarksGETHandler swagger:operation GET /api/v1/bookmarks bookmarksGet
//
// Get an array of statuses bookmarked in the instance
//
// ---
// tags:
// - bookmarks
//
// produces:
// - application/json
//
// security:
// - OAuth2 Bearer:
// - read:bookmarks
//
// responses:
// '200':
// description: Array of bookmarked statuses
// schema:
// type: array
// items:
// "$ref": "#/definitions/status"
// headers:
// Link:
// type: string
// description: Links to the next and previous queries.
// '401':
// description: unauthorized
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) BookmarksGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil {
api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
return
}
limit := 30
limitString := c.Query(LimitKey)
if limitString != "" {
i, err := strconv.ParseInt(limitString, 10, 64)
if err != nil {
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
return
}
limit = int(i)
}
maxID := ""
maxIDString := c.Query(MaxIDKey)
if maxIDString != "" {
maxID = maxIDString
}
minID := ""
minIDString := c.Query(MinIDKey)
if minIDString != "" {
minID = minIDString
}
resp, errWithCode := m.processor.BookmarksGet(c.Request.Context(), authed, maxID, minID, limit)
if errWithCode != nil {
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return
}
if errWithCode != nil {
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
c.JSON(http.StatusOK, resp.Items)
}

View file

@ -96,6 +96,9 @@ func (m *Module) Route(r router.Router) error {
r.AttachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler) r.AttachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler)
r.AttachHandler(http.MethodGet, RebloggedPath, m.StatusBoostedByGETHandler) r.AttachHandler(http.MethodGet, RebloggedPath, m.StatusBoostedByGETHandler)
r.AttachHandler(http.MethodPost, BookmarkPath, m.StatusBookmarkPOSTHandler)
r.AttachHandler(http.MethodPost, UnbookmarkPath, m.StatusUnbookmarkPOSTHandler)
r.AttachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler) r.AttachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler)
r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)

View file

@ -0,0 +1,98 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 status
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/api"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// StatusBookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/bookmark statusBookmark
//
// Bookmark status with the given ID.
//
// ---
// tags:
// - statuses
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: Target status ID.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:statuses
//
// responses:
// '200':
// name: status
// description: The status.
// schema:
// "$ref": "#/definitions/status"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) StatusBookmarkPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil {
api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
return
}
targetStatusID := c.Param(IDKey)
if targetStatusID == "" {
err := errors.New("no status id specified")
api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
return
}
apiStatus, errWithCode := m.processor.StatusBookmark(c.Request.Context(), authed, targetStatusID)
if errWithCode != nil {
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return
}
c.JSON(http.StatusOK, apiStatus)
}

View file

@ -0,0 +1,83 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 status_test
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
"github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type StatusBookmarkTestSuite struct {
StatusStandardTestSuite
}
func (suite *StatusBookmarkTestSuite) TestPostBookmark() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.DBTokenToToken(t)
targetStatus := suite.testStatuses["admin_account_status_1"]
// setup
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
ctx.Request.Header.Set("accept", "application/json")
// normally the router would populate these params from the path values,
// but because we're calling the function directly, we need to set them manually.
ctx.Params = gin.Params{
gin.Param{
Key: status.IDKey,
Value: targetStatus.ID,
},
}
suite.statusModule.StatusBookmarkPOSTHandler(ctx)
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
suite.NoError(err)
statusReply := &model.Status{}
err = json.Unmarshal(b, statusReply)
suite.NoError(err)
suite.True(statusReply.Bookmarked)
}
func TestStatusBookmarkTestSuite(t *testing.T) {
suite.Run(t, new(StatusBookmarkTestSuite))
}

View file

@ -0,0 +1,98 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 status
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/api"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// StatusUnbookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/unbookmark statusUnbookmark
//
// Unbookmark status with the given ID.
//
// ---
// tags:
// - statuses
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: Target status ID.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:statuses
//
// responses:
// '200':
// name: status
// description: The status.
// schema:
// "$ref": "#/definitions/status"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) StatusUnbookmarkPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil {
api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
return
}
targetStatusID := c.Param(IDKey)
if targetStatusID == "" {
err := errors.New("no status id specified")
api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
return
}
apiStatus, errWithCode := m.processor.StatusUnbookmark(c.Request.Context(), authed, targetStatusID)
if errWithCode != nil {
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return
}
c.JSON(http.StatusOK, apiStatus)
}

View file

@ -0,0 +1,78 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 status_test
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
"github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type StatusUnbookmarkTestSuite struct {
StatusStandardTestSuite
}
func (suite *StatusUnbookmarkTestSuite) TestPostUnbookmark() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.DBTokenToToken(t)
targetStatus := suite.testStatuses["admin_account_status_1"]
// setup
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnbookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
ctx.Request.Header.Set("accept", "application/json")
ctx.Params = gin.Params{
gin.Param{
Key: status.IDKey,
Value: targetStatus.ID,
},
}
suite.statusModule.StatusUnbookmarkPOSTHandler(ctx)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
suite.NoError(err)
statusReply := &model.Status{}
err = json.Unmarshal(b, statusReply)
suite.NoError(err)
suite.False(statusReply.Bookmarked)
}
func TestStatusUnbookmarkTestSuite(t *testing.T) {
suite.Run(t, new(StatusUnbookmarkTestSuite))
}

View file

@ -73,6 +73,8 @@ type Account interface {
// or replies. // or replies.
GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, Error) GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, Error)
GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, Error)
GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error)
// GetAccountLastPosted simply gets the timestamp of the most recent post by the account. // GetAccountLastPosted simply gets the timestamp of the most recent post by the account.

View file

@ -442,6 +442,38 @@ func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string,
return a.statusesFromIDs(ctx, statusIDs) return a.statusesFromIDs(ctx, statusIDs)
} }
func (a *accountDB) GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, db.Error) {
bookmarks := []*gtsmodel.StatusBookmark{}
q := a.conn.
NewSelect().
TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")).
Order("status_bookmark.id DESC").
Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID)
if accountID == "" {
return nil, errors.New("must provide an account")
}
if limit != 0 {
q = q.Limit(limit)
}
if maxID != "" {
q = q.Where("? < ?", bun.Ident("status_bookmark.id"), maxID)
}
if minID != "" {
q = q.Where("? > ?", bun.Ident("status_bookmark.id"), minID)
}
if err := q.Scan(ctx, &bookmarks); err != nil {
return nil, a.conn.ProcessError(err)
}
return bookmarks, nil
}
func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, db.Error) { func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, db.Error) {
blocks := []*gtsmodel.Block{} blocks := []*gtsmodel.Block{}

View file

@ -208,6 +208,12 @@ func (suite *AccountTestSuite) TestInsertAccountWithDefaults() {
suite.False(*newAccount.HideCollections) suite.False(*newAccount.HideCollections)
} }
func (suite *AccountTestSuite) TestGettingBookmarksWithNoAccount() {
statuses, err := suite.db.GetBookmarks(context.Background(), "", 10, "", "")
suite.Error(err)
suite.Nil(statuses)
}
func TestAccountTestSuite(t *testing.T) { func TestAccountTestSuite(t *testing.T) {
suite.Run(t, new(AccountTestSuite)) suite.Run(t, new(AccountTestSuite))
} }

View file

@ -64,6 +64,9 @@ type Processor interface {
// WebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only // WebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only
// statuses which are suitable for showing on the public web profile of an account. // statuses which are suitable for showing on the public web profile of an account.
WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, gtserror.WithCode) WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, gtserror.WithCode)
// StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
// the account given in authed.
BookmarksGet(ctx context.Context, requestingAccount *gtsmodel.Account, limit int, maxID string, minID string) (*apimodel.PageableResponse, gtserror.WithCode)
// FollowersGet fetches a list of the target account's followers. // FollowersGet fetches a list of the target account's followers.
FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
// FollowingGet fetches a list of the accounts that target account is following. // FollowingGet fetches a list of the accounts that target account is following.

View file

@ -0,0 +1,88 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 account
import (
"context"
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmodel.Account, limit int, maxID string, minID string) (*apimodel.PageableResponse, gtserror.WithCode) {
if requestingAccount == nil {
return nil, gtserror.NewErrorForbidden(fmt.Errorf("cannot retrieve bookmarks without a requesting account"))
}
bookmarks, err := p.db.GetBookmarks(ctx, requestingAccount.ID, limit, maxID, minID)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
count := len(bookmarks)
filtered := make([]*gtsmodel.Status, 0, len(bookmarks))
nextMaxIDValue := ""
prevMinIDValue := ""
for i, b := range bookmarks {
s, err := p.db.GetStatusByID(ctx, b.StatusID)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
if err == nil && visible {
if i == count-1 {
nextMaxIDValue = b.ID
}
if i == 0 {
prevMinIDValue = b.ID
}
filtered = append(filtered, s)
}
}
count = len(filtered)
if count == 0 {
return util.EmptyPageableResponse(), nil
}
items := []interface{}{}
for _, s := range filtered {
item, err := p.tc.StatusToAPIStatus(ctx, s, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err))
}
items = append(items, item)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/bookmarks",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
ExtraQueryParams: []string{},
})
}

View file

@ -0,0 +1,31 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 processing
import (
"context"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
func (p *processor) BookmarksGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
return p.accountProcessor.BookmarksGet(ctx, authed.Account, limit, maxID, minID)
}

View file

@ -146,6 +146,9 @@ type Processor interface {
// CustomEmojisGet returns an array of info about the custom emojis on this server // CustomEmojisGet returns an array of info about the custom emojis on this server
CustomEmojisGet(ctx context.Context) ([]*apimodel.Emoji, gtserror.WithCode) CustomEmojisGet(ctx context.Context) ([]*apimodel.Emoji, gtserror.WithCode)
// BookmarksGet returns a pageable response of statuses that have been bookmarked
BookmarksGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode)
// FileGet handles the fetching of a media attachment file via the fileserver. // FileGet handles the fetching of a media attachment file via the fileserver.
FileGet(ctx context.Context, authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, gtserror.WithCode) FileGet(ctx context.Context, authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, gtserror.WithCode)
@ -202,6 +205,10 @@ type Processor interface {
StatusUnfave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) StatusUnfave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// StatusGetContext returns the context (previous and following posts) from the given status ID // StatusGetContext returns the context (previous and following posts) from the given status ID
StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode)
// StatusBookmark process a bookmark for a status
StatusBookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// StatusUnbookmark removes a bookmark for a status
StatusUnbookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// HomeTimelineGet returns statuses from the home timeline, with the given filters/parameters. // HomeTimelineGet returns statuses from the home timeline, with the given filters/parameters.
HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode)

View file

@ -65,3 +65,11 @@ func (p *processor) StatusUnfave(ctx context.Context, authed *oauth.Auth, target
func (p *processor) StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) { func (p *processor) StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
return p.statusProcessor.Context(ctx, authed.Account, targetStatusID) return p.statusProcessor.Context(ctx, authed.Account, targetStatusID)
} }
func (p *processor) StatusBookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
return p.statusProcessor.Bookmark(ctx, authed.Account, targetStatusID)
}
func (p *processor) StatusUnbookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
return p.statusProcessor.Unbookmark(ctx, authed.Account, targetStatusID)
}

View file

@ -0,0 +1,86 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 status
import (
"context"
"errors"
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
)
func (p *processor) Bookmark(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
targetStatus, err := p.db.GetStatusByID(ctx, targetStatusID)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err))
}
if targetStatus.Account == nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID))
}
visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err))
}
if !visible {
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible"))
}
// first check if the status is already bookmarked, if so we don't need to do anything
newBookmark := true
gtsBookmark := &gtsmodel.StatusBookmark{}
if err := p.db.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err == nil {
// we already have a bookmark for this status
newBookmark = false
}
if newBookmark {
thisBookmarkID, err := id.NewULID()
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
// we need to create a new bookmark in the database
gtsBookmark := &gtsmodel.StatusBookmark{
ID: thisBookmarkID,
AccountID: requestingAccount.ID,
Account: requestingAccount,
TargetAccountID: targetStatus.AccountID,
TargetAccount: targetStatus.Account,
StatusID: targetStatus.ID,
Status: targetStatus,
}
if err := p.db.Put(ctx, gtsBookmark); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error putting bookmark in database: %s", err))
}
}
// return the apidon representation of the target status
apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err))
}
return apiStatus, nil
}

View file

@ -0,0 +1,48 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 status_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
)
type StatusBookmarkTestSuite struct {
StatusStandardTestSuite
}
func (suite *StatusBookmarkTestSuite) TestBookmark() {
ctx := context.Background()
// bookmark a status
bookmarkingAccount1 := suite.testAccounts["local_account_1"]
targetStatus1 := suite.testStatuses["admin_account_status_1"]
bookmark1, err := suite.status.Bookmark(ctx, bookmarkingAccount1, targetStatus1.ID)
suite.NoError(err)
suite.NotNil(bookmark1)
suite.True(bookmark1.Bookmarked)
suite.Equal(targetStatus1.ID, bookmark1.ID)
}
func TestStatusBookmarkTestSuite(t *testing.T) {
suite.Run(t, new(StatusBookmarkTestSuite))
}

View file

@ -54,6 +54,10 @@ type Processor interface {
Unfave(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) Unfave(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// Context returns the context (previous and following posts) from the given status ID // Context returns the context (previous and following posts) from the given status ID
Context(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) Context(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode)
// Bookmarks a status
Bookmark(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// Removes a bookmark for a status
Unbookmark(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
/* /*
PROCESSING UTILS PROCESSING UTILS

View file

@ -0,0 +1,69 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 status
import (
"context"
"errors"
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func (p *processor) Unbookmark(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
targetStatus, err := p.db.GetStatusByID(ctx, targetStatusID)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err))
}
if targetStatus.Account == nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID))
}
visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err))
}
if !visible {
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible"))
}
// first check if the status is already bookmarked
toUnbookmark := false
gtsBookmark := &gtsmodel.StatusBookmark{}
if err := p.db.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err == nil {
// we already have a bookmark for this status
toUnbookmark = true
}
if toUnbookmark {
if err := p.db.DeleteWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error unfaveing status: %s", err))
}
}
// return the apidon representation of the target status
apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err))
}
return apiStatus, nil
}

View file

@ -0,0 +1,54 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
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 status_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
)
type StatusUnbookmarkTestSuite struct {
StatusStandardTestSuite
}
func (suite *StatusUnbookmarkTestSuite) TestUnbookmark() {
ctx := context.Background()
// bookmark a status
bookmarkingAccount1 := suite.testAccounts["local_account_1"]
targetStatus1 := suite.testStatuses["admin_account_status_1"]
bookmark1, err := suite.status.Bookmark(ctx, bookmarkingAccount1, targetStatus1.ID)
suite.NoError(err)
suite.NotNil(bookmark1)
suite.True(bookmark1.Bookmarked)
suite.Equal(targetStatus1.ID, bookmark1.ID)
bookmark2, err := suite.status.Unbookmark(ctx, bookmarkingAccount1, targetStatus1.ID)
suite.NoError(err)
suite.NotNil(bookmark2)
suite.False(bookmark2.Bookmarked)
suite.Equal(targetStatus1.ID, bookmark1.ID)
}
func TestStatusUnbookmarkTestSuite(t *testing.T) {
suite.Run(t, new(StatusUnbookmarkTestSuite))
}

View file

@ -93,7 +93,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
b, err := json.Marshal(apiStatus) b, err := json.Marshal(apiStatus)
suite.NoError(err) suite.NoError(err)
suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":false,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b)) suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":true,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b))
} }
func (suite *InternalToFrontendTestSuite) TestInstanceToFrontend() { func (suite *InternalToFrontendTestSuite) TestInstanceToFrontend() {

View file

@ -261,6 +261,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
} }
} }
for _, v := range NewTestBookmarks() {
if err := db.Put(ctx, v); err != nil {
log.Panic(err)
}
}
if err := db.CreateInstanceAccount(ctx); err != nil { if err := db.CreateInstanceAccount(ctx); err != nil {
log.Panic(err) log.Panic(err)
} }

View file

@ -2251,6 +2251,26 @@ func NewTestFediStatuses() map[string]vocab.ActivityStreamsNote {
} }
} }
// NewTestBookmarks returns a map of gts model bookmarks, keyed in the format [bookmarking_account]_[target_status]
func NewTestBookmarks() map[string]*gtsmodel.StatusBookmark {
return map[string]*gtsmodel.StatusBookmark{
"local_account_1_admin_account_status_1": {
ID: "01F8MHD2QCZSZ6WQS2ATVPEYJ9",
CreatedAt: TimeMustParse("2022-05-14T13:21:09+02:00"),
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", // local account 1
TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF", // admin account
StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R", // admin account status 1
},
"admin_account_local_account_1_status_1": {
ID: "01F8Q0486ANTDWKG02A7DS1Q24",
CreatedAt: TimeMustParse("2022-05-14T13:21:09+02:00"),
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", // admin account
TargetAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", // local account 1
StatusID: "01F8MHAMCHF6Y650WCRSCP4WMY", // local account status 1
},
}
}
// NewTestDereferenceRequests returns a map of incoming dereference requests, with their signatures. // NewTestDereferenceRequests returns a map of incoming dereference requests, with their signatures.
func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature { func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature {
var sig, digest, date string var sig, digest, date string