commit c48abd8bc0e708fedcddd7985c00243580464452
parent ea1bbacf4b51628f55bc831f511ce60ddb72d71c
Author: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 22 May 2023 16:32:36 +0200
[chore] update account statuses paging logic (#1814)
Diffstat:
5 files changed, 388 insertions(+), 125 deletions(-)
diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go
@@ -29,6 +29,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
@@ -475,48 +476,41 @@ func (a *accountDB) CountAccountPinned(ctx context.Context, accountID string) (i
}
func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, db.Error) {
- statusIDs := []string{}
+ // Ensure reasonable
+ if limit < 0 {
+ limit = 0
+ }
+
+ // Make educated guess for slice size
+ var (
+ statusIDs = make([]string, 0, limit)
+ frontToBack = true
+ )
q := a.conn.
NewSelect().
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
+ // Select only IDs from table
Column("status.id").
- Order("status.id DESC")
-
- if accountID != "" {
- q = q.Where("? = ?", bun.Ident("status.account_id"), accountID)
- }
-
- if limit != 0 {
- q = q.Limit(limit)
- }
+ Where("? = ?", bun.Ident("status.account_id"), accountID)
if excludeReplies {
- // include self-replies (threads)
- whereGroup := func(*bun.SelectQuery) *bun.SelectQuery {
+ q = q.WhereGroup(" AND ", func(*bun.SelectQuery) *bun.SelectQuery {
return q.
- WhereOr("? = ?", bun.Ident("status.in_reply_to_account_id"), accountID).
- WhereGroup(" OR ", whereEmptyOrNull("status.in_reply_to_uri"))
- }
-
- q = q.WhereGroup(" AND ", whereGroup)
+ // Do include self replies (threads), but
+ // don't include replies to other people.
+ Where("? = ?", bun.Ident("status.in_reply_to_account_id"), accountID).
+ WhereOr("? IS NULL", bun.Ident("status.in_reply_to_uri"))
+ })
}
if excludeReblogs {
- q = q.WhereGroup(" AND ", whereEmptyOrNull("status.boost_of_id"))
- }
-
- if maxID != "" {
- q = q.Where("? < ?", bun.Ident("status.id"), maxID)
- }
-
- if minID != "" {
- q = q.Where("? > ?", bun.Ident("status.id"), minID)
+ q = q.Where("? IS NULL", bun.Ident("status.boost_of_id"))
}
if mediaOnly {
- // attachments are stored as a json object;
- // this implementation differs between sqlite and postgres,
+ // Attachments are stored as a json object; this
+ // implementation differs between SQLite and Postgres,
// so we have to be thorough to cover all eventualities
q = q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
switch a.conn.Dialect().Name() {
@@ -542,10 +536,46 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li
q = q.Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic)
}
+ // return only statuses LOWER (ie., older) than maxID
+ if maxID == "" {
+ maxID = id.Highest
+ }
+ q = q.Where("? < ?", bun.Ident("status.id"), maxID)
+
+ if minID != "" {
+ // return only statuses HIGHER (ie., newer) than minID
+ q = q.Where("? > ?", bun.Ident("status.id"), minID)
+
+ // page up
+ frontToBack = false
+ }
+
+ if limit > 0 {
+ // limit amount of statuses returned
+ q = q.Limit(limit)
+ }
+
+ if frontToBack {
+ // Page down.
+ q = q.Order("status.id DESC")
+ } else {
+ // Page up.
+ q = q.Order("status.id ASC")
+ }
+
if err := q.Scan(ctx, &statusIDs); err != nil {
return nil, a.conn.ProcessError(err)
}
+ // If we're paging up, we still want statuses
+ // to be sorted by ID desc, so reverse ids slice.
+ // https://zchee.github.io/golang-wiki/SliceTricks/#reversing
+ if !frontToBack {
+ for l, r := 0, len(statusIDs)-1; l < r; l, r = l+1, r-1 {
+ statusIDs[l], statusIDs[r] = statusIDs[r], statusIDs[l]
+ }
+ }
+
return a.statusesFromIDs(ctx, statusIDs)
}
@@ -568,23 +598,45 @@ func (a *accountDB) GetAccountPinnedStatuses(ctx context.Context, accountID stri
}
func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, db.Error) {
- statusIDs := []string{}
+ // Ensure reasonable
+ if limit < 0 {
+ limit = 0
+ }
+
+ // Make educated guess for slice size
+ statusIDs := make([]string, 0, limit)
q := a.conn.
NewSelect().
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
+ // Select only IDs from table
Column("status.id").
Where("? = ?", bun.Ident("status.account_id"), accountID).
- WhereGroup(" AND ", whereEmptyOrNull("status.in_reply_to_uri")).
- WhereGroup(" AND ", whereEmptyOrNull("status.boost_of_id")).
+ // Don't show replies or boosts.
+ Where("? IS NULL", bun.Ident("status.in_reply_to_uri")).
+ Where("? IS NULL", bun.Ident("status.boost_of_id")).
+ // Only Public statuses.
Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic).
+ // Don't show local-only statuses on the web view.
Where("? = ?", bun.Ident("status.federated"), true)
- if maxID != "" {
- q = q.Where("? < ?", bun.Ident("status.id"), maxID)
+ // return only statuses LOWER (ie., older) than maxID
+ if maxID == "" {
+ maxID = id.Highest
+ }
+ q = q.Where("? < ?", bun.Ident("status.id"), maxID)
+
+ if limit > 0 {
+ // limit amount of statuses returned
+ q = q.Limit(limit)
+ }
+
+ if limit > 0 {
+ // limit amount of statuses returned
+ q = q.Limit(limit)
}
- q = q.Limit(limit).Order("status.id DESC")
+ q = q.Order("status.id DESC")
if err := q.Scan(ctx, &statusIDs); err != nil {
return nil, a.conn.ProcessError(err)
diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go
@@ -45,6 +45,34 @@ func (suite *AccountTestSuite) TestGetAccountStatuses() {
suite.Len(statuses, 5)
}
+func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
+ // get the first page
+ statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 2, false, false, "", "", false, false)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.Len(statuses, 2)
+
+ // get the second page
+ statuses, err = suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 2, false, false, statuses[len(statuses)-1].ID, "", false, false)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.Len(statuses, 2)
+
+ // get the third page
+ statuses, err = suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 2, false, false, statuses[len(statuses)-1].ID, "", false, false)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.Len(statuses, 1)
+
+ // try to get the last page (should be empty)
+ statuses, err = suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 2, false, false, statuses[len(statuses)-1].ID, "", false, false)
+ suite.ErrorIs(err, db.ErrNoEntries)
+ suite.Empty(statuses)
+}
+
func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogs() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, false)
suite.NoError(err)
diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go
@@ -26,16 +26,32 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
-// StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
-// the account given in authed.
-func (p *Processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) (*apimodel.PageableResponse, gtserror.WithCode) {
+// StatusesGet fetches a number of statuses (in time descending order) from the
+// target account, filtered by visibility according to the requesting account.
+func (p *Processor) StatusesGet(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ targetAccountID string,
+ limit int,
+ excludeReplies bool,
+ excludeReblogs bool,
+ maxID string,
+ minID string,
+ pinned bool,
+ mediaOnly bool,
+ publicOnly bool,
+) (*apimodel.PageableResponse, gtserror.WithCode) {
if requestingAccount != nil {
- if blocked, err := p.state.DB.IsEitherBlocked(ctx, requestingAccount.ID, targetAccountID); err != nil {
+ blocked, err := p.state.DB.IsEitherBlocked(ctx, requestingAccount.ID, targetAccountID)
+ if err != nil {
return nil, gtserror.NewErrorInternalError(err)
- } else if blocked {
+ }
+
+ if blocked {
err := errors.New("block exists between accounts")
return nil, gtserror.NewErrorNotFound(err)
}
@@ -45,6 +61,7 @@ func (p *Processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
statuses []*gtsmodel.Status
err error
)
+
if pinned {
// Get *ONLY* pinned statuses.
statuses, err = p.state.DB.GetAccountPinnedStatuses(ctx, targetAccountID)
@@ -52,14 +69,17 @@ func (p *Processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
// Get account statuses which *may* include pinned ones.
statuses, err = p.state.DB.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, mediaOnly, publicOnly)
}
- if err != nil {
- if err == db.ErrNoEntries {
- return util.EmptyPageableResponse(), nil
- }
+
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorInternalError(err)
}
- // Filtering + serialization process is the same for either pinned status queries or 'normal' ones.
+ if len(statuses) == 0 {
+ return util.EmptyPageableResponse(), nil
+ }
+
+ // Filtering + serialization process is the same for
+ // both pinned status queries and 'normal' ones.
filtered, err := p.filter.StatusesVisible(ctx, requestingAccount, statuses)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
@@ -67,24 +87,32 @@ func (p *Processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
count := len(filtered)
if count == 0 {
+ // After filtering there were
+ // no statuses left to serve.
return util.EmptyPageableResponse(), nil
}
- items := make([]interface{}, 0, count)
- nextMaxIDValue := ""
- prevMinIDValue := ""
- for i, 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))
- }
+ var (
+ items = make([]interface{}, 0, count)
+ nextMaxIDValue string
+ prevMinIDValue string
+ )
+ for i, s := range filtered {
+ // Set next + prev values before filtering and API
+ // converting, so caller can still page properly.
if i == count-1 {
- nextMaxIDValue = item.GetID()
+ nextMaxIDValue = s.ID
}
if i == 0 {
- prevMinIDValue = item.GetID()
+ prevMinIDValue = s.ID
+ }
+
+ item, err := p.tc.StatusToAPIStatus(ctx, s, requestingAccount)
+ if err != nil {
+ log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", s.ID, err)
+ continue
}
items = append(items, item)
@@ -100,7 +128,7 @@ func (p *Processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
- Path: fmt.Sprintf("/api/v1/accounts/%s/statuses", targetAccountID),
+ Path: "/api/v1/accounts/" + targetAccountID + "/statuses",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
@@ -114,62 +142,58 @@ func (p *Processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
})
}
-// 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.
+// 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.
func (p *Processor) WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, gtserror.WithCode) {
- acct, err := p.state.DB.GetAccountByID(ctx, targetAccountID)
+ account, err := p.state.DB.GetAccountByID(ctx, targetAccountID)
if err != nil {
- if err == db.ErrNoEntries {
+ if errors.Is(err, db.ErrNoEntries) {
err := fmt.Errorf("account %s not found in the db, not getting web statuses for it", targetAccountID)
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
- if acct.Domain != "" {
+ if account.Domain != "" {
err := fmt.Errorf("account %s was not a local account, not getting web statuses for it", targetAccountID)
return nil, gtserror.NewErrorNotFound(err)
}
statuses, err := p.state.DB.GetAccountWebStatuses(ctx, targetAccountID, 10, maxID)
- if err != nil {
- if err == db.ErrNoEntries {
- return util.EmptyPageableResponse(), nil
- }
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorInternalError(err)
}
count := len(statuses)
-
if count == 0 {
return util.EmptyPageableResponse(), nil
}
- items := []interface{}{}
- nextMaxIDValue := ""
- prevMinIDValue := ""
- for i, s := range statuses {
- item, err := p.tc.StatusToAPIStatus(ctx, s, nil)
- if err != nil {
- return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err))
- }
+ var (
+ items = make([]interface{}, 0, count)
+ nextMaxIDValue string
+ )
+ for i, s := range statuses {
+ // Set next value before API converting,
+ // so caller can still page properly.
if i == count-1 {
- nextMaxIDValue = item.GetID()
+ nextMaxIDValue = s.ID
}
- if i == 0 {
- prevMinIDValue = item.GetID()
+ item, err := p.tc.StatusToAPIStatus(ctx, s, nil)
+ if err != nil {
+ log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", s.ID, err)
+ continue
}
items = append(items, item)
}
return util.PackagePageableResponse(util.PageableResponseParams{
- Items: items,
- Path: "/@" + acct.Username,
- NextMaxIDValue: nextMaxIDValue,
- PrevMinIDValue: prevMinIDValue,
- ExtraQueryParams: []string{},
+ Items: items,
+ Path: "/@" + account.Username,
+ NextMaxIDValue: nextMaxIDValue,
})
}
diff --git a/internal/util/paging.go b/internal/util/paging.go
@@ -20,6 +20,7 @@ package util
import (
"fmt"
"net/url"
+ "strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
@@ -47,6 +48,13 @@ type PageableResponseParams struct {
// a bunch of pageable items (notifications, statuses, etc), as well
// as a Link header to inform callers of where to find next/prev items.
func PackagePageableResponse(params PageableResponseParams) (*apimodel.PageableResponse, gtserror.WithCode) {
+ if len(params.Items) == 0 {
+ // No items to page through.
+ return EmptyPageableResponse(), nil
+ }
+
+ // Set default paging values, if
+ // they weren't set by the caller.
if params.NextMaxIDKey == "" {
params.NextMaxIDKey = "max_id"
}
@@ -55,58 +63,70 @@ func PackagePageableResponse(params PageableResponseParams) (*apimodel.PageableR
params.PrevMinIDKey = "min_id"
}
- pageableResponse := EmptyPageableResponse()
+ var (
+ protocol = config.GetProtocol()
+ host = config.GetHost()
+ nextLink string
+ prevLink string
+ linkHeaderParts = make([]string, 0, 2)
+ )
- if len(params.Items) == 0 {
- return pageableResponse, nil
- }
+ // Parse next link.
+ if params.NextMaxIDValue != "" {
+ nextRaw := params.NextMaxIDKey + "=" + params.NextMaxIDValue
- // items
- pageableResponse.Items = params.Items
+ if params.Limit != 0 {
+ nextRaw = fmt.Sprintf("limit=%d&", params.Limit) + nextRaw
+ }
- protocol := config.GetProtocol()
- host := config.GetHost()
+ for _, p := range params.ExtraQueryParams {
+ nextRaw += "&" + p
+ }
- // next
- nextRaw := params.NextMaxIDKey + "=" + params.NextMaxIDValue
- if params.Limit != 0 {
- nextRaw = fmt.Sprintf("limit=%d&", params.Limit) + nextRaw
- }
- for _, p := range params.ExtraQueryParams {
- nextRaw = nextRaw + "&" + p
- }
- nextLink := &url.URL{
- Scheme: protocol,
- Host: host,
- Path: params.Path,
- RawQuery: nextRaw,
- }
- nextLinkString := nextLink.String()
- pageableResponse.NextLink = nextLinkString
+ nextLink = func() string {
+ u := &url.URL{
+ Scheme: protocol,
+ Host: host,
+ Path: params.Path,
+ RawQuery: nextRaw,
+ }
+ return u.String()
+ }()
- // prev
- prevRaw := params.PrevMinIDKey + "=" + params.PrevMinIDValue
- if params.Limit != 0 {
- prevRaw = fmt.Sprintf("limit=%d&", params.Limit) + prevRaw
+ linkHeaderParts = append(linkHeaderParts, `<`+nextLink+`>; rel="next"`)
}
- for _, p := range params.ExtraQueryParams {
- prevRaw = prevRaw + "&" + p
- }
- prevLink := &url.URL{
- Scheme: protocol,
- Host: host,
- Path: params.Path,
- RawQuery: prevRaw,
- }
- prevLinkString := prevLink.String()
- pageableResponse.PrevLink = prevLinkString
- // link header
- next := fmt.Sprintf("<%s>; rel=\"next\"", nextLinkString)
- prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLinkString)
- pageableResponse.LinkHeader = next + ", " + prev
+ // Parse prev link.
+ if params.PrevMinIDValue != "" {
+ prevRaw := params.PrevMinIDKey + "=" + params.PrevMinIDValue
+
+ if params.Limit != 0 {
+ prevRaw = fmt.Sprintf("limit=%d&", params.Limit) + prevRaw
+ }
+
+ for _, p := range params.ExtraQueryParams {
+ prevRaw = prevRaw + "&" + p
+ }
- return pageableResponse, nil
+ prevLink = func() string {
+ u := &url.URL{
+ Scheme: protocol,
+ Host: host,
+ Path: params.Path,
+ RawQuery: prevRaw,
+ }
+ return u.String()
+ }()
+
+ linkHeaderParts = append(linkHeaderParts, `<`+prevLink+`>; rel="prev"`)
+ }
+
+ return &apimodel.PageableResponse{
+ Items: params.Items,
+ LinkHeader: strings.Join(linkHeaderParts, ", "),
+ NextLink: nextLink,
+ PrevLink: prevLink,
+ }, nil
}
// EmptyPageableResponse just returns an empty
diff --git a/internal/util/paging_test.go b/internal/util/paging_test.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 util_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+type PagingSuite struct {
+ suite.Suite
+}
+
+func (suite *PagingSuite) TestPagingStandard() {
+ config.SetHost("example.org")
+
+ params := util.PageableResponseParams{
+ Items: make([]interface{}, 10, 10),
+ Path: "/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses",
+ NextMaxIDValue: "01H11KA1DM2VH3747YDE7FV5HN",
+ PrevMinIDValue: "01H11KBBVRRDYYC5KEPME1NP5R",
+ Limit: 10,
+ }
+
+ resp, errWithCode := util.PackagePageableResponse(params)
+ if errWithCode != nil {
+ suite.FailNow(errWithCode.Error())
+ }
+
+ suite.Equal(make([]interface{}, 10, 10), resp.Items)
+ suite.Equal(`<https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?limit=10&max_id=01H11KA1DM2VH3747YDE7FV5HN>; rel="next", <https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?limit=10&min_id=01H11KBBVRRDYYC5KEPME1NP5R>; rel="prev"`, resp.LinkHeader)
+ suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?limit=10&max_id=01H11KA1DM2VH3747YDE7FV5HN`, resp.NextLink)
+ suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?limit=10&min_id=01H11KBBVRRDYYC5KEPME1NP5R`, resp.PrevLink)
+}
+
+func (suite *PagingSuite) TestPagingNoLimit() {
+ config.SetHost("example.org")
+
+ params := util.PageableResponseParams{
+ Items: make([]interface{}, 10, 10),
+ Path: "/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses",
+ NextMaxIDValue: "01H11KA1DM2VH3747YDE7FV5HN",
+ PrevMinIDValue: "01H11KBBVRRDYYC5KEPME1NP5R",
+ }
+
+ resp, errWithCode := util.PackagePageableResponse(params)
+ if errWithCode != nil {
+ suite.FailNow(errWithCode.Error())
+ }
+
+ suite.Equal(make([]interface{}, 10, 10), resp.Items)
+ suite.Equal(`<https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?max_id=01H11KA1DM2VH3747YDE7FV5HN>; rel="next", <https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?min_id=01H11KBBVRRDYYC5KEPME1NP5R>; rel="prev"`, resp.LinkHeader)
+ suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?max_id=01H11KA1DM2VH3747YDE7FV5HN`, resp.NextLink)
+ suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?min_id=01H11KBBVRRDYYC5KEPME1NP5R`, resp.PrevLink)
+}
+
+func (suite *PagingSuite) TestPagingNoNextID() {
+ config.SetHost("example.org")
+
+ params := util.PageableResponseParams{
+ Items: make([]interface{}, 10, 10),
+ Path: "/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses",
+ PrevMinIDValue: "01H11KBBVRRDYYC5KEPME1NP5R",
+ Limit: 10,
+ }
+
+ resp, errWithCode := util.PackagePageableResponse(params)
+ if errWithCode != nil {
+ suite.FailNow(errWithCode.Error())
+ }
+
+ suite.Equal(make([]interface{}, 10, 10), resp.Items)
+ suite.Equal(`<https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?limit=10&min_id=01H11KBBVRRDYYC5KEPME1NP5R>; rel="prev"`, resp.LinkHeader)
+ suite.Equal(``, resp.NextLink)
+ suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?limit=10&min_id=01H11KBBVRRDYYC5KEPME1NP5R`, resp.PrevLink)
+}
+
+func (suite *PagingSuite) TestPagingNoPrevID() {
+ config.SetHost("example.org")
+
+ params := util.PageableResponseParams{
+ Items: make([]interface{}, 10, 10),
+ Path: "/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses",
+ NextMaxIDValue: "01H11KA1DM2VH3747YDE7FV5HN",
+ Limit: 10,
+ }
+
+ resp, errWithCode := util.PackagePageableResponse(params)
+ if errWithCode != nil {
+ suite.FailNow(errWithCode.Error())
+ }
+
+ suite.Equal(make([]interface{}, 10, 10), resp.Items)
+ suite.Equal(`<https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?limit=10&max_id=01H11KA1DM2VH3747YDE7FV5HN>; rel="next"`, resp.LinkHeader)
+ suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?limit=10&max_id=01H11KA1DM2VH3747YDE7FV5HN`, resp.NextLink)
+ suite.Equal(``, resp.PrevLink)
+}
+
+func (suite *PagingSuite) TestPagingNoItems() {
+ config.SetHost("example.org")
+
+ params := util.PageableResponseParams{
+ NextMaxIDValue: "01H11KA1DM2VH3747YDE7FV5HN",
+ PrevMinIDValue: "01H11KBBVRRDYYC5KEPME1NP5R",
+ Limit: 10,
+ }
+
+ resp, errWithCode := util.PackagePageableResponse(params)
+ if errWithCode != nil {
+ suite.FailNow(errWithCode.Error())
+ }
+
+ suite.Empty(resp.Items)
+ suite.Empty(resp.LinkHeader)
+ suite.Empty(resp.NextLink)
+ suite.Empty(resp.PrevLink)
+}
+
+func TestPagingSuite(t *testing.T) {
+ suite.Run(t, &PagingSuite{})
+}