diff --git a/internal/models/entity.go b/internal/models/entity.go index 473e0b7b0..ecf776ed8 100644 --- a/internal/models/entity.go +++ b/internal/models/entity.go @@ -10,6 +10,7 @@ import ( "time" "github.com/hookdeck/outpost/internal/cursor" + "github.com/hookdeck/outpost/internal/pagination" "github.com/hookdeck/outpost/internal/redis" ) @@ -358,99 +359,38 @@ func (s *entityStoreImpl) ListTenant(ctx context.Context, req ListTenantRequest) return nil, ErrInvalidOrder } - // Determine sort direction - sortDir := "DESC" - if order == "asc" { - sortDir = "ASC" - } - - // Parse cursor timestamp (keyset pagination) - // Cursor contains the timestamp boundary for the next/prev page - var cursorTimestamp int64 - isNextCursor := false - isPrevCursor := false - if req.Next != "" { - data, err := cursor.Decode(req.Next, "tnt", 1) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidCursor, err) - } - cursorTimestamp, err = strconv.ParseInt(data, 10, 64) - if err != nil { - return nil, fmt.Errorf("%w: invalid timestamp", ErrInvalidCursor) - } - isNextCursor = true - } else if req.Prev != "" { - data, err := cursor.Decode(req.Prev, "tnt", 1) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidCursor, err) - } - cursorTimestamp, err = strconv.ParseInt(data, 10, 64) - if err != nil { - return nil, fmt.Errorf("%w: invalid timestamp", ErrInvalidCursor) - } - isPrevCursor = true - } - - // Build FT.SEARCH query with timestamp filter (keyset pagination) - // This avoids offset-based pagination which has issues on Dragonfly - // We use inclusive ranges with cursor-1/cursor+1 for better compatibility + // Base filter for tenant search // Filter: @entity:{tenant} ensures only tenant records (not destinations) // Filter: -@deleted_at:[1 +inf] excludes deleted tenants - // (documents without deleted_at field won't match the positive query, so negation includes them) baseFilter := "@entity:{tenant} -@deleted_at:[1 +inf]" - var query string - querySortDir := sortDir - - if !isNextCursor && !isPrevCursor { - // First page - only filter out deleted - query = baseFilter - } else if isNextCursor { - // Next cursor: get older (DESC) or newer (ASC) records - if order == "desc" { - // DESC: get records with created_at < cursor (older) - query = fmt.Sprintf("(@created_at:[0 %d]) %s", cursorTimestamp-1, baseFilter) - } else { - // ASC: get records with created_at > cursor (newer) - query = fmt.Sprintf("(@created_at:[%d +inf]) %s", cursorTimestamp+1, baseFilter) - } - } else { - // Prev cursor: go back to previous page - // We reverse the sort direction to get items closest to the cursor, - // then reverse the results to maintain the user's expected order. - if order == "desc" { - // DESC going back: get records with created_at > cursor (newer), sorted ASC - query = fmt.Sprintf("(@created_at:[%d +inf]) %s", cursorTimestamp+1, baseFilter) - querySortDir = "ASC" - } else { - // ASC going back: get records with created_at < cursor (older), sorted DESC - query = fmt.Sprintf("(@created_at:[0 %d]) %s", cursorTimestamp-1, baseFilter) - querySortDir = "DESC" - } - } - - // Execute FT.SEARCH query with LIMIT 0 count (no offset) - result, err := s.doCmd(ctx, "FT.SEARCH", s.tenantIndexName(), - query, - "SORTBY", "created_at", querySortDir, - "LIMIT", 0, limit, - ).Result() - if err != nil { - return nil, fmt.Errorf("failed to search tenants: %w", err) - } - // Parse FT.SEARCH result - tenants, _, err := s.parseSearchResult(ctx, result) + // Use pagination package for cursor-based pagination with n+1 pattern + result, err := pagination.Run(ctx, pagination.Config[Tenant]{ + Limit: limit, + Order: order, + Next: req.Next, + Prev: req.Prev, + Cursor: pagination.Cursor[Tenant]{ + Encode: func(t Tenant) string { + return cursor.Encode("tnt", 1, strconv.FormatInt(t.CreatedAt.UnixMilli(), 10)) + }, + Decode: func(c string) (string, error) { + data, err := cursor.Decode(c, "tnt", 1) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidCursor, err) + } + return data, nil + }, + }, + Fetch: func(ctx context.Context, q pagination.QueryInput) ([]Tenant, error) { + return s.fetchTenants(ctx, baseFilter, q) + }, + }) if err != nil { return nil, err } - // For prev cursor, we fetched in reverse order to get items closest to cursor. - // Now reverse the results to restore the user's expected order. - if isPrevCursor { - for i, j := 0, len(tenants)-1; i < j; i, j = i+1, j-1 { - tenants[i], tenants[j] = tenants[j], tenants[i] - } - } + tenants := result.Items // Batch fetch destination summaries for all tenants in a single Redis round-trip if len(tenants) > 0 { @@ -484,30 +424,61 @@ func (s *entityStoreImpl) ListTenant(ctx context.Context, req ListTenantRequest) _, totalCount, _ = s.parseSearchResult(ctx, countResult) } - // Build response with cursors - resp := &ListTenantResponse{ + return &ListTenantResponse{ Data: tenants, Count: totalCount, - } + Next: result.Next, + Prev: result.Prev, + }, nil +} - // Always set next/prev cursors based on first/last item timestamps. - // Client discovers "no more pages" when they use the cursor and get empty result. - // This avoids expensive probe queries. - if len(tenants) > 0 { - lastTenant := tenants[len(tenants)-1] - firstTenant := tenants[0] +// fetchTenants builds and executes the FT.SEARCH query for tenant pagination. +func (s *entityStoreImpl) fetchTenants(ctx context.Context, baseFilter string, q pagination.QueryInput) ([]Tenant, error) { + // Build FT.SEARCH query with timestamp filter (keyset pagination) + var query string + sortDir := "DESC" + if q.SortDir == "asc" { + sortDir = "ASC" + } - // Next cursor: points to last item (for continuing in same direction) - resp.Next = cursor.Encode("tnt", 1, strconv.FormatInt(lastTenant.CreatedAt.UnixMilli(), 10)) + if q.CursorPos == "" { + // First page - only filter out deleted + query = baseFilter + } else { + // Parse cursor timestamp + cursorTimestamp, err := strconv.ParseInt(q.CursorPos, 10, 64) + if err != nil { + return nil, fmt.Errorf("%w: invalid timestamp", ErrInvalidCursor) + } - // Prev cursor: points to first item (for going back) - // Only set if we navigated here via a cursor (not on first page) - if isNextCursor || isPrevCursor { - resp.Prev = cursor.Encode("tnt", 1, strconv.FormatInt(firstTenant.CreatedAt.UnixMilli(), 10)) + // Build cursor condition based on compare operator + // Use inclusive ranges with cursor-1/cursor+1 for better compatibility + if q.Compare == "<" { + // Get records with created_at < cursor + query = fmt.Sprintf("(@created_at:[0 %d]) %s", cursorTimestamp-1, baseFilter) + } else { + // Get records with created_at > cursor + query = fmt.Sprintf("(@created_at:[%d +inf]) %s", cursorTimestamp+1, baseFilter) } } - return resp, nil + // Execute FT.SEARCH query + result, err := s.doCmd(ctx, "FT.SEARCH", s.tenantIndexName(), + query, + "SORTBY", "created_at", sortDir, + "LIMIT", 0, q.Limit, + ).Result() + if err != nil { + return nil, fmt.Errorf("failed to search tenants: %w", err) + } + + // Parse FT.SEARCH result + tenants, _, err := s.parseSearchResult(ctx, result) + if err != nil { + return nil, err + } + + return tenants, nil } // parseSearchResult parses the FT.SEARCH result into a list of tenants. diff --git a/internal/models/entity_test.go b/internal/models/entity_test.go index e45b9525f..4cb489948 100644 --- a/internal/models/entity_test.go +++ b/internal/models/entity_test.go @@ -9,6 +9,7 @@ import ( "github.com/hookdeck/outpost/internal/idgen" "github.com/hookdeck/outpost/internal/models" + "github.com/hookdeck/outpost/internal/pagination/paginationtest" "github.com/hookdeck/outpost/internal/redis" "github.com/hookdeck/outpost/internal/util/testinfra" "github.com/hookdeck/outpost/internal/util/testutil" @@ -165,6 +166,113 @@ func TestListTenant_Dragonfly_WithDeploymentID(t *testing.T) { }) } +// ============================================================================= +// ListTenant Pagination Suite - Tests using paginationtest.Suite +// These tests verify correct cursor-based pagination behavior including: +// - Forward/backward traversal +// - n+1 pattern (hasMore detection) +// - First page has no Prev, last page has no Next +// ============================================================================= + +func TestListTenantPagination(t *testing.T) { + t.Parallel() + runListTenantPaginationSuite(t, dragonflyStackClientFactory, "") +} + +func TestListTenantPagination_WithDeploymentID(t *testing.T) { + t.Parallel() + runListTenantPaginationSuite(t, dragonflyStackClientFactory, "dp_pagination_test") +} + +func TestListTenantPagination_Compat_RedisStack(t *testing.T) { + t.Parallel() + runListTenantPaginationSuite(t, redisStackClientFactory, "") +} + +func TestListTenantPagination_Compat_RedisStack_WithDeploymentID(t *testing.T) { + t.Parallel() + runListTenantPaginationSuite(t, redisStackClientFactory, "dp_pagination_test") +} + +func runListTenantPaginationSuite(t *testing.T, factory RedisClientFactory, deploymentID string) { + ctx := context.Background() + redisClient := factory(t) + + // Add unique suffix to deployment ID to isolate test data between parallel runs + if deploymentID != "" { + deploymentID = fmt.Sprintf("%s_%d", deploymentID, time.Now().UnixNano()) + } else { + deploymentID = fmt.Sprintf("pagination_test_%d", time.Now().UnixNano()) + } + + entityStore := models.NewEntityStore(redisClient, + models.WithCipher(models.NewAESCipher("secret")), + models.WithAvailableTopics(testutil.TestTopics), + models.WithDeploymentID(deploymentID), + ) + + // Initialize entity store (probes for RediSearch) + err := entityStore.Init(ctx) + require.NoError(t, err) + + // Track created tenant IDs for cleanup between subtests + var createdTenantIDs []string + baseTime := time.Now() + + paginationSuite := paginationtest.Suite[models.Tenant]{ + Name: "entitystore_ListTenant", + + NewItem: func(index int) models.Tenant { + return models.Tenant{ + ID: fmt.Sprintf("tenant_pagination_%d_%d", time.Now().UnixNano(), index), + CreatedAt: baseTime.Add(time.Duration(index) * time.Second), + UpdatedAt: baseTime.Add(time.Duration(index) * time.Second), + } + }, + + InsertMany: func(ctx context.Context, items []models.Tenant) error { + for _, item := range items { + if err := entityStore.UpsertTenant(ctx, item); err != nil { + return err + } + createdTenantIDs = append(createdTenantIDs, item.ID) + } + return nil + }, + + List: func(ctx context.Context, opts paginationtest.ListOpts) (paginationtest.ListResult[models.Tenant], error) { + resp, err := entityStore.ListTenant(ctx, models.ListTenantRequest{ + Limit: opts.Limit, + Order: opts.Order, + Next: opts.Next, + Prev: opts.Prev, + }) + if err != nil { + return paginationtest.ListResult[models.Tenant]{}, err + } + return paginationtest.ListResult[models.Tenant]{ + Items: resp.Data, + Next: resp.Next, + Prev: resp.Prev, + }, nil + }, + + GetID: func(t models.Tenant) string { + return t.ID + }, + + Cleanup: func(ctx context.Context) error { + for _, id := range createdTenantIDs { + _ = entityStore.DeleteTenant(ctx, id) + } + createdTenantIDs = nil + return nil + }, + } + + paginationSuite.Run(t) +} + // TestDestinationCredentialsEncryption verifies that credentials and delivery_metadata // are properly encrypted when stored in Redis. // diff --git a/internal/models/entitysuite_test.go b/internal/models/entitysuite_test.go index 57d453ea2..2378c783e 100644 --- a/internal/models/entitysuite_test.go +++ b/internal/models/entitysuite_test.go @@ -1208,24 +1208,12 @@ func (s *ListTenantTestSuite) SetupSuite() { } } -func (s *ListTenantTestSuite) TestListTenantBasic() { +// TestListTenantEnrichment tests ListTenant-specific fields: Count, destinations_count, topics. +// Pagination behavior is tested separately in paginationtest.Suite. +func (s *ListTenantTestSuite) TestListTenantEnrichment() { // Uses 25 tenants created in SetupSuite, first tenant has 2 destinations - s.T().Run("lists all tenants", func(t *testing.T) { - resp, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Limit: 100}) - require.NoError(t, err) - assert.Len(t, resp.Data, 25) - }) - - s.T().Run("respects limit", func(t *testing.T) { - resp, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Limit: 2}) - require.NoError(t, err) - assert.Len(t, resp.Data, 2) - assert.NotEmpty(t, resp.Next, "should have next cursor") - assert.Empty(t, resp.Prev, "should not have prev cursor on first page") - }) - - s.T().Run("returns total count", func(t *testing.T) { + s.T().Run("returns total count independent of pagination", func(t *testing.T) { // First page with limit resp1, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Limit: 2}) require.NoError(t, err) @@ -1236,26 +1224,23 @@ func (s *ListTenantTestSuite) TestListTenantBasic() { resp2, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Limit: 2, Next: resp1.Next}) require.NoError(t, err) assert.Equal(t, 25, resp2.Count, "count should remain total across pages") - assert.Len(t, resp2.Data, 2) }) - s.T().Run("does not count destinations in tenant list", func(t *testing.T) { - // Verify destinations don't appear in tenant list + s.T().Run("does not include destinations in tenant list", func(t *testing.T) { resp, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Limit: 100}) require.NoError(t, err) assert.Equal(t, 25, resp.Count, "count should be tenants only") - // Verify no destination IDs in the data for _, tenant := range resp.Data { assert.NotContains(t, tenant.ID, "dest_", "destination should not appear in tenant list") } }) s.T().Run("returns destinations_count and topics", func(t *testing.T) { - // tenantWithDests has 2 destinations from SetupSuite resp, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Limit: 100}) require.NoError(t, err) + // tenantWithDests has 2 destinations from SetupSuite var tenantWithDests *models.Tenant for i := range resp.Data { if resp.Data[i].ID == s.tenantWithDests.ID { @@ -1279,171 +1264,6 @@ func (s *ListTenantTestSuite) TestListTenantBasic() { assert.Equal(t, 0, tenantWithoutDests.DestinationsCount, "tenant without destinations should have 0 count") assert.Empty(t, tenantWithoutDests.Topics, "tenant without destinations should have empty topics") }) - - s.T().Run("orders by created_at desc by default", func(t *testing.T) { - resp, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Limit: 100}) - require.NoError(t, err) - require.Len(t, resp.Data, 25) - - // Most recent first (desc order) - for i := 1; i < len(resp.Data); i++ { - assert.True(t, resp.Data[i-1].CreatedAt.After(resp.Data[i].CreatedAt) || - resp.Data[i-1].CreatedAt.Equal(resp.Data[i].CreatedAt), - "tenant %d should have created_at >= tenant %d", i-1, i) - } - }) - - s.T().Run("orders by created_at asc", func(t *testing.T) { - resp, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Order: "asc", Limit: 100}) - require.NoError(t, err) - require.Len(t, resp.Data, 25) - - // Oldest first (asc order) - for i := 1; i < len(resp.Data); i++ { - assert.True(t, resp.Data[i-1].CreatedAt.Before(resp.Data[i].CreatedAt) || - resp.Data[i-1].CreatedAt.Equal(resp.Data[i].CreatedAt), - "tenant %d should have created_at <= tenant %d", i-1, i) - } - }) -} - -func (s *ListTenantTestSuite) TestListTenantPagination() { - // Uses 25 tenants created in SetupSuite - - s.T().Run("paginate forward through all pages", func(t *testing.T) { - var allTenants []models.Tenant - cursor := "" - pageCount := 0 - var firstResp, lastResp *models.ListTenantResponse - - for { - resp, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{ - Limit: 10, - Next: cursor, - }) - require.NoError(t, err) - - // Stop when we get empty result (no more data) - if len(resp.Data) == 0 { - break - } - - allTenants = append(allTenants, resp.Data...) - pageCount++ - if firstResp == nil { - firstResp = resp - } - lastResp = resp - cursor = resp.Next - - // Safety check to prevent infinite loop - require.Less(t, pageCount, 10, "too many pages") - } - - assert.Equal(t, 25, len(allTenants), "should have retrieved all tenants") - assert.Equal(t, 3, pageCount, "should have 3 pages (10+10+5)") - assert.Empty(t, firstResp.Prev, "first page should have no prev cursor") - // Last page has next cursor - using it returns empty (which terminated the loop) - assert.NotEmpty(t, lastResp.Next, "last page should have next cursor") - }) - - s.T().Run("full forward and backward traversal", func(t *testing.T) { - // This test verifies that prev cursor enables traditional "go back to previous page" behavior. - // Forward: Page1 -> Page2 -> Page3 - // Backward: Page3 -> Page2 -> Page1 - // The same tenants should appear on each page regardless of direction. - - // Forward traversal: collect all pages - var forwardPages [][]models.Tenant - cursor := "" - for { - resp, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{ - Limit: 10, - Next: cursor, - }) - require.NoError(t, err) - - // Stop when we get empty result - if len(resp.Data) == 0 { - break - } - - forwardPages = append(forwardPages, resp.Data) - cursor = resp.Next - } - require.Equal(t, 3, len(forwardPages), "should have 3 pages forward") - - // Now traverse backward from page 3 to page 1 - // Start from page 3, go to page 2, then page 1 - // Get page 3 again to get its prev cursor - resp3, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{ - Limit: 10, - Next: cursor, // This is the cursor that got us to page 3 - }) - require.NoError(t, err) - - // Actually we need to re-fetch page 2 first to get its prev cursor - // Let's fetch forward again to page 2 and get its prev cursor - page1, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Limit: 10}) - require.NoError(t, err) - require.NotEmpty(t, page1.Next) - - page2, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{ - Limit: 10, - Next: page1.Next, - }) - require.NoError(t, err) - require.NotEmpty(t, page2.Next) - require.NotEmpty(t, page2.Prev, "page 2 should have prev cursor") - - page3, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{ - Limit: 10, - Next: page2.Next, - }) - require.NoError(t, err) - require.NotEmpty(t, page3.Prev, "page 3 should have prev cursor") - _ = resp3 // silence unused warning - - // Now go backward: use page3's prev cursor to get page 2 - backToPage2, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{ - Limit: 10, - Prev: page3.Prev, - }) - require.NoError(t, err) - require.Len(t, backToPage2.Data, 10, "going back to page 2 should return 10 items") - - // Verify we got the same items as page 2 (forward) - for i, tenant := range backToPage2.Data { - assert.Equal(t, page2.Data[i].ID, tenant.ID, - "backward page 2 item %d should match forward page 2", i) - } - - // Go back one more time: use backToPage2's prev cursor to get page 1 - require.NotEmpty(t, backToPage2.Prev, "page 2 (backward) should have prev cursor") - backToPage1, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{ - Limit: 10, - Prev: backToPage2.Prev, - }) - require.NoError(t, err) - require.Len(t, backToPage1.Data, 10, "going back to page 1 should return 10 items") - - // Verify we got the same items as page 1 (forward) - for i, tenant := range backToPage1.Data { - assert.Equal(t, page1.Data[i].ID, tenant.ID, - "backward page 1 item %d should match forward page 1", i) - } - - // Page 1 (backward) has prev cursor - client discovers empty when using it - assert.NotEmpty(t, backToPage1.Prev, "page 1 (backward) should have prev cursor") - - // Verify using prev cursor on page 1 returns empty - emptyResp, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{ - Limit: 10, - Prev: backToPage1.Prev, - }) - require.NoError(t, err) - assert.Empty(t, emptyResp.Data, "using prev cursor on page 1 should return empty") - }) } func (s *ListTenantTestSuite) TestListTenantExcludesDeleted() { @@ -1526,85 +1346,9 @@ func (s *ListTenantTestSuite) TestListTenantExcludesDeleted() { }) } -// TestListTenantPaginationEdgeCases demonstrates the limitations of offset-based pagination. -// These tests document known edge cases, not bugs to fix. -func (s *ListTenantTestSuite) TestListTenantPaginationEdgeCases() { - s.T().Run("delete during traversal may cause skipped tenant", func(t *testing.T) { - // This test documents a known limitation of offset-based pagination: - // If a tenant is deleted from an already-fetched page, subsequent pages - // may shift, causing one tenant to be skipped. - // - // Example with limit=5, sorted DESC (newest first): - // Initial order by created_at DESC: [14, 13, 12, 11, 10, 09, 08, ...] - // Page 1 (offset 0): [14, 13, 12, 11, 10] - fetched, cursor = offset 5 - // Delete tenant 12 (position 2 on page 1) - // After deletion, positions shift: [14, 13, 11, 10, 09, 08, ...] - // Page 2 (offset 5): [08, 07, 06, ...] - tenant 09 shifted to position 4, SKIPPED! - // - // Note: This behavior depends on RediSearch index update timing, which is async. - // The test documents the scenario but doesn't hard-assert since timing varies. - - // Create 15 tenants with unique prefix and timestamps far in the future - prefix := fmt.Sprintf("del_edge_%d_", time.Now().UnixNano()) - tenantIDs := make([]string, 15) - baseTime := time.Now().Add(10 * time.Hour) - for i := 0; i < 15; i++ { - tenantIDs[i] = fmt.Sprintf("%s%02d", prefix, i) - tenant := models.Tenant{ - ID: tenantIDs[i], - CreatedAt: baseTime.Add(time.Duration(i) * time.Second), - UpdatedAt: baseTime, - } - require.NoError(t, s.entityStore.UpsertTenant(s.ctx, tenant)) - } - - // With DESC order: position 0 = tenantIDs[14], ..., position 5 = tenantIDs[9] - expectedSkippedTenant := tenantIDs[9] - - // Fetch page 1 - resp1, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{Limit: 5}) - require.NoError(t, err) - require.Len(t, resp1.Data, 5, "page 1 should have 5 items") - - // Verify we got our test tenants - for _, tenant := range resp1.Data { - require.Contains(t, tenant.ID, prefix, "page 1 should contain our test tenants") - } - - // Delete a tenant from the middle of page 1 - deletedID := resp1.Data[2].ID - require.NoError(t, s.entityStore.DeleteTenant(s.ctx, deletedID)) - - // Fetch page 2 using the cursor from page 1 - resp2, err := s.entityStore.ListTenant(s.ctx, models.ListTenantRequest{ - Limit: 5, - Next: resp1.Next, - }) - require.NoError(t, err) - - // Collect all seen IDs - seenIDs := make(map[string]bool) - for _, tenant := range resp1.Data { - seenIDs[tenant.ID] = true - } - for _, tenant := range resp2.Data { - seenIDs[tenant.ID] = true - } - - // Document what happened (informational, not a hard assertion) - if !seenIDs[expectedSkippedTenant] { - t.Logf("EDGE CASE MANIFESTED: tenant %s was skipped due to offset shift after deletion", expectedSkippedTenant) - } else { - t.Logf("Note: tenant %s was NOT skipped (RediSearch index update timing may vary)", expectedSkippedTenant) - } - - // Cleanup - for _, id := range tenantIDs { - _ = s.entityStore.DeleteTenant(s.ctx, id) - } - }) - - s.T().Run("add during traversal does NOT cause duplicate (keyset pagination)", func(t *testing.T) { +// TestListTenantKeysetPagination verifies keyset pagination handles concurrent modifications correctly. +func (s *ListTenantTestSuite) TestListTenantKeysetPagination() { + s.T().Run("add during traversal does not cause duplicate", func(t *testing.T) { // With keyset pagination, adding a new item with a newer timestamp // does NOT cause duplicates because the cursor is based on timestamp, // not offset. The new item falls outside the timestamp range.