Files
go/page/cursor_test.go
T
end c45e8abe08
Documentation / docs (push) Successful in 29s
ci / check (push) Successful in 41s
ci / postgres (push) Successful in 45s
feat(page): add typed keyset pagination for Bob (#15)
Add transport-neutral keyset pagination through native Bob query mods.
Preserve supported cursor value types in a compact versioned format, with
bidirectional pages, explicit row keys, and a separate opt-in count.
Include SQLite tests, PostgreSQL service CI, and the cursor-format ADR.

Closes #6
2026-10-27 20:21:58 -07:00

221 lines
8.5 KiB
Go

package page
import (
"encoding/base64"
"encoding/json"
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
"uuid"
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/psql"
pd "github.com/stephenafamo/bob/dialect/psql/dialect"
"github.com/stephenafamo/bob/expr"
)
func scalarSort() Sort {
return Sort{ID: "scalar/v1", Columns: []Column{{Expression: expr.Quote("id"), Unique: true}}}
}
func TestSupportedValues(t *testing.T) {
instant := time.Date(2026, 10, 27, 12, 34, 56, 123456789, time.FixedZone("odd", 3661))
values := []any{
int(0), int(math.MaxInt), int(math.MinInt), int8(math.MinInt8), int8(math.MaxInt8),
int16(math.MinInt16), int16(math.MaxInt16), int32(math.MinInt32), int32(math.MaxInt32),
int64(math.MinInt64), int64(math.MaxInt64), int64(9007199254740993), "", "こんにちは\x00' OR 1=1 --",
instant, time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC),
uuid.MustParse("01234567-89ab-cdef-0123-456789abcdef"), uuid.Nil(),
}
for _, value := range values {
t.Run(reflect.TypeOf(value).String(), func(t *testing.T) {
token, err := encodeCursor("scalar/v1", []any{value})
if err != nil {
t.Fatal(err)
}
decoded, err := decodeCursor(token, scalarSort())
if err != nil {
t.Fatal(err)
}
expected := value
if v, ok := value.(time.Time); ok {
expected = v.UTC()
}
if !reflect.DeepEqual(decoded, []any{expected}) {
t.Fatalf("type/value: got %#v want %#v", decoded, expected)
}
p, err := Prepare(scalarSort(), func(v any) []any { return []any{v} }, Request{Cursor: token}, Limits{Default: 1, Maximum: 2})
if err != nil {
t.Fatal(err)
}
q := psql.Select(Mod[*pd.SelectQuery](p))
_, args, err := bob.Build(t.Context(), q)
if err != nil || !reflect.DeepEqual(args, decoded) {
t.Fatalf("bound values: %#v %v", args, err)
}
if tm, ok := expected.(time.Time); ok {
data, _ := base64.RawURLEncoding.DecodeString(token)
if !strings.Contains(string(data), tm.Format(time.RFC3339Nano)) {
t.Fatalf("non-UTC timestamp: %s", data)
}
}
})
}
}
func TestUnsupportedValues(t *testing.T) {
type named int64
n := 1
values := []any{nil, named(1), uint(1), uint64(1), float64(1), true, &n, []byte("x"), struct{}{}, "\xff", time.Date(-1, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)}
for _, value := range values {
p, err := Prepare(scalarSort(), func(v any) []any { return []any{v} }, Request{}, Limits{Default: 1, Maximum: 2})
if err != nil {
t.Fatal(err)
}
result, err := p.Page([]any{value})
var valueError *ValueError
if !errors.As(err, &valueError) || valueError.Position != 0 || !reflect.DeepEqual(result, Page[any]{}) {
t.Fatalf("%T: %+v %v", value, result, err)
}
}
type alias = int64
if _, err := encodeCursor("scalar/v1", []any{alias(1)}); err != nil {
t.Fatal(err)
}
}
func TestMalformedCursors(t *testing.T) {
valid := `{"v":1,"sort":"scalar/v1","values":[["int64","1"]]}`
cases := []struct{ data, kind string }{
{`null`, "malformed"}, {`[]`, "malformed"}, {`{`, "malformed"},
{valid + ` {}`, "malformed"},
{strings.Replace(valid, `"v":1`, `"v":2`, 1), "version"},
{strings.Replace(valid, `"v":1`, `"v":null`, 1), "malformed"},
{strings.Replace(valid, `"v":1`, `"v":1,"v":1`, 1), "malformed"},
{strings.Replace(valid, `"v":1,`, ``, 1), "malformed"},
{strings.Replace(valid, `"v":1`, `"v":1,"columns":[]`, 1), "malformed"},
{strings.Replace(valid, `scalar/v1`, `other/v1`, 1), "sort"},
{strings.Replace(valid, `"scalar/v1"`, `null`, 1), "malformed"},
{strings.Replace(valid, `[["int64","1"]]`, `null`, 1), "malformed"},
{strings.Replace(valid, `[["int64","1"]]`, `[]`, 1), "malformed"},
{strings.Replace(valid, `[["int64","1"]]`, `[["int64","1"],["int64","2"]]`, 1), "malformed"},
}
for _, entry := range []string{`null`, `{}`, `{"type":"int64","value":"1"}`, `[]`, `["int64"]`, `["int64","1","x"]`, `[null,"1"]`, `["int64",null]`, `["int64",1]`, `[1,"1"]`} {
cases = append(cases, struct{ data, kind string }{strings.Replace(valid, `["int64","1"]`, entry, 1), "malformed"})
}
for _, pair := range [][2]string{{"what", "1"}, {"int8", "128"}, {"int8", "-129"}, {"int16", "32768"}, {"int32", "2147483648"}, {"int64", "9223372036854775808"}, {"int", "999999999999999999999"}, {"int64", "01"}, {"int64", "+1"}, {"int64", "-0"}, {"int64", "1.0"}, {"time", "10000-01-01T00:00:00Z"}, {"time", "yesterday"}, {"time", "9999-12-31T23:59:59-01:00"}, {"time", "0000-01-01T00:00:00+01:00"}, {"uuid", "broken"}} {
data, _ := json.Marshal(pair)
cases = append(cases, struct{ data, kind string }{strings.Replace(valid, `["int64","1"]`, string(data), 1), "value"})
}
for i, tc := range cases {
_, err := decodeCursor(base64.RawURLEncoding.EncodeToString([]byte(tc.data)), scalarSort())
var ce *CursorError
if !errors.As(err, &ce) || ce.Kind != tc.kind {
t.Fatalf("case %d %s: %v", i, tc.data, err)
}
}
for _, token := range []string{"!", "====", base64.RawURLEncoding.EncodeToString([]byte(valid)) + "\n", base64.RawURLEncoding.EncodeToString([]byte("\xff"))} {
if _, err := decodeCursor(token, scalarSort()); err == nil {
t.Fatalf("accepted %q", token)
}
}
// A well-formed edit is a new seek boundary, not an authentication failure.
edited := strings.Replace(valid, `"1"`, `"9007199254740993"`, 1)
got, err := decodeCursor(base64.RawURLEncoding.EncodeToString([]byte(edited)), scalarSort())
if err != nil || got[0] != int64(9007199254740993) {
t.Fatalf("edited boundary: %v %v", got, err)
}
}
func TestCursorSize(t *testing.T) {
// Find the exact final-envelope boundary instead of assuming an overhead size.
var at string
var length int
for length = 12000; length < 12400; length++ {
token, err := encodeCursor("scalar/v1", []any{strings.Repeat("x", length)})
if err == nil && len(token) == maxCursorBytes {
at = token
break
}
}
if at == "" {
t.Fatal("exact boundary not found")
}
if _, err := decodeCursor(at, scalarSort()); err != nil {
t.Fatal(err)
}
var ce *CursorError
if _, err := decodeCursor(at+"A", scalarSort()); !errors.As(err, &ce) || ce.Kind != "size" {
t.Fatalf("received limit: %v", err)
}
p, err := Prepare(scalarSort(), func(v string) []any { return []any{v} }, Request{}, Limits{Default: 1, Maximum: 2})
if err != nil {
t.Fatal(err)
}
result, err := p.Page([]string{strings.Repeat("x", length+1)})
if !errors.As(err, &ce) || ce.Kind != "size" || !reflect.DeepEqual(result, Page[string]{}) {
t.Fatalf("emitted limit: %+v %v", result, err)
}
if _, err := encodeCursor("scalar/v1", []any{strings.Repeat("x", length-1)}); err != nil {
t.Fatal(err)
}
// Failure in an overflow row also invalidates the entire page.
result, err = p.Page([]string{"valid", strings.Repeat("x", length+1)})
if !errors.As(err, &ce) || !reflect.DeepEqual(result, Page[string]{}) {
t.Fatalf("overflow: %+v %v", result, err)
}
}
func TestPrepareAndKeyValidation(t *testing.T) {
limits := Limits{Default: 2, Maximum: 3}
badSorts := []Sort{{}, {ID: "x"}, {ID: "x", Columns: []Column{{Unique: true}}}, {ID: "x", Columns: []Column{{Expression: expr.Quote("id"), Direction: Order(2), Unique: true}}}, {ID: "x", Columns: []Column{{Expression: expr.Quote("id")}}}}
key := func(v int) []any { return []any{v} }
for _, sort := range badSorts {
if _, err := Prepare(sort, key, Request{}, limits); err == nil {
t.Fatalf("accepted sort: %+v", sort)
}
}
if _, err := Prepare[int](scalarSort(), nil, Request{}, limits); err == nil {
t.Fatal("nil key accepted")
}
for _, l := range []Limits{{}, {Default: 2, Maximum: 1}, {Default: 1, Maximum: math.MaxInt}} {
if _, err := Prepare(scalarSort(), key, Request{}, l); err == nil {
t.Fatalf("accepted limits %+v", l)
}
}
for _, r := range []Request{{Size: -1}, {Direction: Direction(2)}} {
if _, err := Prepare(scalarSort(), key, r, limits); err == nil {
t.Fatal("bad request accepted")
}
}
for _, size := range []int{0, 1, 3, 4, math.MaxInt} {
p, err := Prepare(scalarSort(), key, Request{Size: size}, limits)
want := min(size, 3)
if size == 0 {
want = 2
}
if err != nil || p.size != want {
t.Fatalf("size %d: %+v %v", size, p, err)
}
}
for _, badLength := range []int{0, 2} {
p, err := Prepare(scalarSort(), func(v int) []any {
if v == 9 {
return make([]any, badLength)
}
return []any{v}
}, Request{Size: 1}, limits)
if err != nil {
t.Fatal(err)
}
result, err := p.Page([]int{1, 9})
var ke *KeyError
if !errors.As(err, &ke) || ke.Expected != 1 || ke.Actual != badLength || !reflect.DeepEqual(result, Page[int]{}) {
t.Fatalf("key: %+v %v", result, err)
}
}
}