package pagination import ( "encoding/base64" "testing" "time" "github.com/stretchr/testify/require" "gorm.io/driver/postgres" "gorm.io/gorm" ) type testItem struct { id string createdAt time.Time } func TestPageByCursorReturnsNextCursorAndNextPage(t *testing.T) { now := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC) items := []testItem{ {id: "c", createdAt: now}, {id: "b", createdAt: now.Add(-time.Minute)}, {id: "a", createdAt: now.Add(-2 * time.Minute)}, } key := func(item testItem) (time.Time, string) { return item.createdAt, item.id } firstPage, nextCursor, err := PageByCursor(items, 2, "", key) require.NoError(t, err) require.Len(t, firstPage, 2) require.NotEmpty(t, nextCursor) secondPage, nextCursor, err := PageByCursor(items, 2, nextCursor, key) require.NoError(t, err) require.Len(t, secondPage, 1) require.Empty(t, nextCursor) require.Equal(t, "a", secondPage[0].id) } func TestSortByKeyDescUsesIDAsTieBreaker(t *testing.T) { now := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC) items := []testItem{ {id: "a", createdAt: now}, {id: "c", createdAt: now}, {id: "b", createdAt: now}, } SortByKeyDesc(items, func(item testItem) (time.Time, string) { return item.createdAt, item.id }) require.Equal(t, []string{"c", "b", "a"}, []string{ items[0].id, items[1].id, items[2].id, }) } func TestEncodeReturnsEmptyForInvalidInput(t *testing.T) { require.Empty(t, Encode(time.Time{}, "id")) require.Empty(t, Encode(time.Now(), " ")) } func TestDecodeRejectsInvalidCursor(t *testing.T) { t.Run("blank cursor is nil", func(t *testing.T) { cursor, err := Decode(" ") require.NoError(t, err) require.Nil(t, cursor) }) t.Run("invalid base64 returns error", func(t *testing.T) { cursor, err := Decode("not base64") require.Error(t, err) require.Nil(t, cursor) }) t.Run("invalid json returns error", func(t *testing.T) { raw := base64.RawURLEncoding.EncodeToString([]byte("{invalid")) cursor, err := Decode(raw) require.Error(t, err) require.Nil(t, cursor) }) t.Run("missing timestamp returns invalid cursor", func(t *testing.T) { raw := base64.RawURLEncoding.EncodeToString([]byte(`{"id":"abc"}`)) cursor, err := Decode(raw) require.EqualError(t, err, "invalid cursor") require.Nil(t, cursor) }) t.Run("missing id returns invalid cursor", func(t *testing.T) { raw := base64.RawURLEncoding.EncodeToString([]byte(`{"timestamp":"2026-05-29T00:00:00Z"}`)) cursor, err := Decode(raw) require.EqualError(t, err, "invalid cursor") require.Nil(t, cursor) }) } func TestComesAfter(t *testing.T) { now := time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC) cursor := &Cursor{Timestamp: now, ID: "m"} require.True(t, ComesAfter(now, "id", nil)) require.True(t, ComesAfter(now.Add(-time.Second), "z", cursor)) require.True(t, ComesAfter(now, "a", cursor)) require.False(t, ComesAfter(now.Add(time.Second), "a", cursor)) require.False(t, ComesAfter(now, "z", cursor)) } func TestPageByCursorReturnsDecodeError(t *testing.T) { items := []testItem{{id: "a", createdAt: time.Now()}} page, nextCursor, err := PageByCursor(items, 10, "not base64", func(item testItem) (time.Time, string) { return item.createdAt, item.id }) require.Error(t, err) require.Nil(t, page) require.Empty(t, nextCursor) } func TestApplyCreatedAtIDCursor(t *testing.T) { db, err := gorm.Open(postgres.New(postgres.Config{ DSN: "host=localhost user=test dbname=test sslmode=disable", }), &gorm.Config{ DryRun: true, DisableAutomaticPing: true, }) require.NoError(t, err) require.Same(t, db, ApplyCreatedAtIDCursor(db, nil, "created_at", "id")) cursor := &Cursor{ Timestamp: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC), ID: "cursor-id", } query := ApplyCreatedAtIDCursor(db.Model(&struct{}{}), cursor, "created_at", "id").Find(&[]struct{}{}) require.Contains(t, query.Statement.SQL.String(), "created_at < $1 OR (created_at = $2 AND id < $3)") require.Equal(t, []any{cursor.Timestamp, cursor.Timestamp, cursor.ID}, query.Statement.Vars) }