Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions core/pagination/pagination.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package pagination

import (
"net/http"

"github.com/Authula/authula/util"
)

const (
DefaultPage = 1
DefaultLimit = 10
MaxLimit = 100
)

type Params struct {
Page int
Limit int
}

type Pagination struct {
Page int `json:"page" required:"true" nullable:"false"`
Limit int `json:"limit" required:"true" nullable:"false"`
Total int `json:"total" required:"true" nullable:"false"`
TotalPages int `json:"total_pages" required:"true" nullable:"false"`
HasMore bool `json:"has_more" required:"true" nullable:"false"`
}

func Clamp(params Params) Params {
if params.Page < DefaultPage {
params.Page = DefaultPage
}
if params.Limit <= 0 {
params.Limit = DefaultLimit
}
if params.Limit > MaxLimit {
params.Limit = MaxLimit
}
return params
}

func New(page, limit, total int) Pagination {
if limit <= 0 {
limit = DefaultLimit
}

totalPages := total / limit
if total%limit != 0 {
totalPages++
}

return Pagination{
Page: page,
Limit: limit,
Total: total,
TotalPages: totalPages,
HasMore: page < totalPages,
}
}

func ParseFromRequest(r *http.Request) Params {
return Params{
Page: util.GetQueryInt(r, "page", DefaultPage),
Limit: util.GetQueryInt(r, "limit", DefaultLimit),
}
}
153 changes: 153 additions & 0 deletions core/pagination/pagination_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package pagination_test

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"

"github.com/Authula/authula/core/pagination"
)

func TestClamp(t *testing.T) {
t.Parallel()

tests := []struct {
name string
params pagination.Params
expected pagination.Params
}{
{
name: "valid params are untouched",
params: pagination.Params{Page: 1, Limit: 10},
expected: pagination.Params{Page: 1, Limit: 10},
},
{
name: "page zero becomes the first page",
params: pagination.Params{Page: 0, Limit: 10},
expected: pagination.Params{Page: 1, Limit: 10},
},
{
name: "negative page becomes the first page",
params: pagination.Params{Page: -5, Limit: 10},
expected: pagination.Params{Page: 1, Limit: 10},
},
{
name: "zero limit falls back to the default limit",
params: pagination.Params{Page: 3, Limit: 0},
expected: pagination.Params{Page: 3, Limit: pagination.DefaultLimit},
},
{
name: "negative limit falls back to the default limit",
params: pagination.Params{Page: 3, Limit: -1},
expected: pagination.Params{Page: 3, Limit: pagination.DefaultLimit},
},
{
name: "limit exactly at the maximum is not clamped",
params: pagination.Params{Page: 1, Limit: pagination.MaxLimit},
expected: pagination.Params{Page: 1, Limit: pagination.MaxLimit},
},
{
name: "limit above the maximum is clamped",
params: pagination.Params{Page: 1, Limit: pagination.MaxLimit + 1},
expected: pagination.Params{Page: 1, Limit: pagination.MaxLimit},
},
{
name: "absurdly large limit is clamped",
params: pagination.Params{Page: 1, Limit: 1_000_000},
expected: pagination.Params{Page: 1, Limit: pagination.MaxLimit},
},
{
name: "high page numbers are legal",
params: pagination.Params{Page: 999999, Limit: 10},
expected: pagination.Params{Page: 999999, Limit: 10},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

require.Equal(t, tt.expected, pagination.Clamp(tt.params))
})
}
}

func TestNew(t *testing.T) {
t.Parallel()

tests := []struct {
name string
page int
limit int
total int
expectedTotalPages int
expectedHasMore bool
}{
{name: "empty result set", page: 1, limit: 10, total: 0, expectedTotalPages: 0, expectedHasMore: false},
{name: "exactly one full page", page: 1, limit: 10, total: 10, expectedTotalPages: 1, expectedHasMore: false},
{name: "one row spilling onto a second page", page: 1, limit: 10, total: 11, expectedTotalPages: 2, expectedHasMore: true},
{name: "middle page has more", page: 2, limit: 25, total: 137, expectedTotalPages: 6, expectedHasMore: true},
{name: "last page has no more", page: 6, limit: 25, total: 137, expectedTotalPages: 6, expectedHasMore: false},
{name: "page past the end has no more", page: 99, limit: 10, total: 5, expectedTotalPages: 1, expectedHasMore: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

result := pagination.New(tt.page, tt.limit, tt.total)
require.Equal(t, tt.page, result.Page)
require.Equal(t, tt.limit, result.Limit)
require.Equal(t, tt.total, result.Total)
require.Equal(t, tt.expectedTotalPages, result.TotalPages)
require.Equal(t, tt.expectedHasMore, result.HasMore)
})
}
}

func TestParseFromRequest(t *testing.T) {
t.Parallel()

tests := []struct {
name string
target string
expected pagination.Params
}{
{
name: "absent query parameters use the defaults",
target: "/organizations",
expected: pagination.Params{Page: pagination.DefaultPage, Limit: pagination.DefaultLimit},
},
{
name: "explicit values are parsed",
target: "/organizations?page=3&limit=50",
expected: pagination.Params{Page: 3, Limit: 50},
},
{
name: "unparseable page falls back to the default page",
target: "/organizations?page=abc",
expected: pagination.Params{Page: pagination.DefaultPage, Limit: pagination.DefaultLimit},
},
{
name: "unparseable limit falls back to the default limit",
target: "/organizations?page=2&limit=xyz",
expected: pagination.Params{Page: 2, Limit: pagination.DefaultLimit},
},
{
name: "out of range values are returned unclamped",
target: "/organizations?page=-4&limit=5000",
expected: pagination.Params{Page: -4, Limit: 5000},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

request := httptest.NewRequest(http.MethodGet, tt.target, nil)
require.Equal(t, tt.expected, pagination.ParseFromRequest(request))
})
}
}
22 changes: 22 additions & 0 deletions internal/tests/data_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,28 @@ func AssertErrorMessage(t *testing.T, reqCtx *models.RequestContext, status int,
}
}

func AssertErrorResponse(t *testing.T, reqCtx *models.RequestContext, status int, code string, message string) {
t.Helper()

if !reqCtx.Handled {
t.Fatal("expected request to be marked as handled")
}
if reqCtx.ResponseStatus != status {
t.Fatalf("expected status %d, got %d", status, reqCtx.ResponseStatus)
}

payload := DecodeResponseJSON[struct {
Code string `json:"code"`
Message string `json:"message"`
}](t, reqCtx)
if payload.Code != code {
t.Fatalf("expected code %q, got %q", code, payload.Code)
}
if payload.Message != message {
t.Fatalf("expected message %q, got %q", message, payload.Message)
}
}

func DecodeResponseJSON[T any](t *testing.T, reqCtx *models.RequestContext) T {
t.Helper()

Expand Down
Loading