Skip to content
Open
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
20 changes: 9 additions & 11 deletions internal/cmd/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import (
"github.com/basecamp/hey-cli/internal/output"
)

const maxAdditionalPages = 100

type boxCommand struct {
cmd *cobra.Command
limit int
Expand Down Expand Up @@ -81,7 +79,7 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error {
} else {
resp.NextHistoryUrl = finalNextURL
}
notice := boxTruncationNotice(len(postings), total, hasMore, c.all)
notice := boxTruncationNotice(len(postings), total, hasMore)

if writer.IsStyled() {
fmt.Fprintf(cmd.OutOrStdout(), "Box: %s (%s)\n\n", resp.Name, resp.Kind)
Expand Down Expand Up @@ -255,15 +253,18 @@ func paginateBoxPostings(ctx context.Context, firstPage *generated.BoxShowRespon
return nil, "", fmt.Errorf("paginateBoxPostings: fetch function is nil while pagination is required")
}

for page := 1; page <= maxAdditionalPages && nextURL != ""; page++ {
seenPageURLs := make(map[string]struct{})
for nextURL != "" {
if _, seen := seenPageURLs[nextURL]; seen {
return nil, "", fmt.Errorf("box pagination loop detected")
}
seenPageURLs[nextURL] = struct{}{}

resp, err := fetch(ctx, nextURL)
if err != nil {
return nil, "", err
}
nextURL = resp.NextHistoryUrl
if len(resp.Postings) == 0 {
break
}
postings = append(postings, resp.Postings...)

if !all && limit > 0 && len(postings) >= limit {
Expand All @@ -275,13 +276,10 @@ func paginateBoxPostings(ctx context.Context, firstPage *generated.BoxShowRespon
}

// boxTruncationNotice returns a user-facing notice about truncated or paginated results.
func boxTruncationNotice(shown, fetched int, hasMore, all bool) string {
func boxTruncationNotice(shown, fetched int, hasMore bool) string {
if shown < fetched {
return fmt.Sprintf("Showing %d of %d results. Use --all to see everything.", shown, fetched)
}
if hasMore && all {
return fmt.Sprintf("Showing %d results. Pagination limit reached; not all results could be fetched.", shown)
}
if hasMore {
return fmt.Sprintf("Showing %d results. More available; use --all to fetch all.", shown)
}
Expand Down
81 changes: 72 additions & 9 deletions internal/cmd/box_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,49 @@ func TestPaginateBoxPostings_AllFlag(t *testing.T) {
}
}

func TestPaginateBoxPostings_AllFlagContinuesPastPreviousPageLimit(t *testing.T) {
first := &generated.BoxShowResponse{
Postings: makePostings(30, 0),
NextHistoryUrl: "https://app.hey.com/page2",
}
pages := make([]generated.BoxShowResponse, 101)
for i := range pages {
pages[i].Postings = makePostings(10, 30+i*10)
if i < len(pages)-1 {
pages[i].NextHistoryUrl = fmt.Sprintf("https://app.hey.com/page%d", i+3)
}
}

postings, nextURL, err := paginateBoxPostings(context.Background(), first, 0, true, mockFetcher(pages))
if err != nil {
t.Fatal(err)
}
if len(postings) != 1040 {
t.Errorf("expected 1040 postings, got %d", len(postings))
}
if nextURL != "" {
t.Errorf("expected empty nextURL when last page has no next URL, got %q", nextURL)
}
}

func TestPaginateBoxPostings_RejectsPaginationLoop(t *testing.T) {
first := &generated.BoxShowResponse{
Postings: makePostings(30, 0),
NextHistoryUrl: "https://app.hey.com/page2",
}
pages := []generated.BoxShowResponse{
{Postings: makePostings(10, 30), NextHistoryUrl: "https://app.hey.com/page2"},
}

_, _, err := paginateBoxPostings(context.Background(), first, 0, true, mockFetcher(pages))
if err == nil {
t.Fatal("expected pagination loop error")
}
if !strings.Contains(err.Error(), "pagination loop") {
t.Errorf("error = %q, want pagination loop error", err)
}
}

func TestPaginateBoxPostings_LimitExceedsFirstPage(t *testing.T) {
first := &generated.BoxShowResponse{
Postings: makePostings(30, 0),
Expand Down Expand Up @@ -196,7 +239,7 @@ func TestPaginateBoxPostings_NoNextURL(t *testing.T) {
}
}

func TestPaginateBoxPostings_EmptyPageStopsPagination(t *testing.T) {
func TestPaginateBoxPostings_EmptyFinalPageStopsPagination(t *testing.T) {
first := &generated.BoxShowResponse{
Postings: makePostings(30, 0),
NextHistoryUrl: "https://app.hey.com/page2",
Expand All @@ -217,6 +260,28 @@ func TestPaginateBoxPostings_EmptyPageStopsPagination(t *testing.T) {
}
}

func TestPaginateBoxPostings_EmptyPageWithNextURLContinuesPagination(t *testing.T) {
first := &generated.BoxShowResponse{
Postings: makePostings(30, 0),
NextHistoryUrl: "https://app.hey.com/page2",
}
pages := []generated.BoxShowResponse{
{NextHistoryUrl: "https://app.hey.com/page3"},
{Postings: makePostings(10, 30)},
}

postings, nextURL, err := paginateBoxPostings(context.Background(), first, 0, true, mockFetcher(pages))
if err != nil {
t.Fatal(err)
}
if len(postings) != 40 {
t.Errorf("expected 40 postings, got %d", len(postings))
}
if nextURL != "" {
t.Errorf("expected empty nextURL when last page has no next URL, got %q", nextURL)
}
}

func TestPaginateBoxPostings_NilFetchReturnsError(t *testing.T) {
first := &generated.BoxShowResponse{
Postings: makePostings(30, 0),
Expand Down Expand Up @@ -257,20 +322,18 @@ func TestBoxTruncationNotice(t *testing.T) {
shown int
fetched int
hasMore bool
all bool
want string
}{
{"client truncated", 10, 30, false, false, "Showing 10 of 30 results. Use --all to see everything."},
{"more pages available", 30, 30, true, false, "Showing 30 results. More available; use --all to fetch all."},
{"all shown no more", 30, 30, false, false, ""},
{"truncated with more", 10, 30, true, false, "Showing 10 of 30 results. Use --all to see everything."},
{"all flag pagination capped", 30, 30, true, true, "Showing 30 results. Pagination limit reached; not all results could be fetched."},
{"client truncated", 10, 30, false, "Showing 10 of 30 results. Use --all to see everything."},
{"more pages available", 30, 30, true, "Showing 30 results. More available; use --all to fetch all."},
{"all shown no more", 30, 30, false, ""},
{"truncated with more", 10, 30, true, "Showing 10 of 30 results. Use --all to see everything."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := boxTruncationNotice(tt.shown, tt.fetched, tt.hasMore, tt.all)
got := boxTruncationNotice(tt.shown, tt.fetched, tt.hasMore)
if got != tt.want {
t.Errorf("boxTruncationNotice(%d, %d, %v, %v) = %q, want %q", tt.shown, tt.fetched, tt.hasMore, tt.all, got, tt.want)
t.Errorf("boxTruncationNotice(%d, %d, %v) = %q, want %q", tt.shown, tt.fetched, tt.hasMore, got, tt.want)
}
})
}
Expand Down