ffec4aade9
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
335 lines
11 KiB
Go
335 lines
11 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
func seedMilestoneIssues(t *testing.T, c *MockClient) {
|
|
t.Helper()
|
|
c.SeedIssue("acme/beta", &Issue{IID: 1, ProjectPath: "acme/beta", Title: "beta-1", Milestone: "Next"})
|
|
c.SeedIssue("acme/alpha", &Issue{IID: 2, ProjectPath: "acme/alpha", Title: "alpha-2", Milestone: "Old"})
|
|
c.SeedIssue("acme/alpha", &Issue{IID: 1, ProjectPath: "acme/alpha", Title: "alpha-1", Milestone: "Next"})
|
|
c.SeedIssue("acme/alpha", &Issue{IID: 3, ProjectPath: "acme/alpha", Title: "alpha-3", Milestone: ""})
|
|
}
|
|
|
|
func TestMockClient_ListIssues_EmptyMilestoneReturnsEverything(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
got, err := c.ListIssues(t.Context(), "", "", "")
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
if len(got) != 4 {
|
|
t.Fatalf("len(got) = %d, want 4", len(got))
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssues_ParameterMilestoneFiltersExact(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
got, err := c.ListIssues(t.Context(), "", "", "Next")
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("len(got) = %d, want 2: %#v", len(got), got)
|
|
}
|
|
// Deterministic order: ProjectPath asc, then IID asc.
|
|
if got[0].ProjectPath != "acme/alpha" || got[0].IID != 1 {
|
|
t.Errorf("got[0] = %+v, want acme/alpha#1", got[0])
|
|
}
|
|
if got[1].ProjectPath != "acme/beta" || got[1].IID != 1 {
|
|
t.Errorf("got[1] = %+v, want acme/beta#1", got[1])
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssues_DeterministicOrderAcrossAllIssues(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
got, err := c.ListIssues(t.Context(), "", "", "")
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
want := []struct {
|
|
Project string
|
|
IID int
|
|
}{
|
|
{"acme/alpha", 1}, {"acme/alpha", 2}, {"acme/alpha", 3}, {"acme/beta", 1},
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
|
|
}
|
|
for i, w := range want {
|
|
if got[i].ProjectPath != w.Project || got[i].IID != w.IID {
|
|
t.Errorf("got[%d] = %s#%d, want %s#%d", i, got[i].ProjectPath, got[i].IID, w.Project, w.IID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssues_CustomQueryMilestoneWinsOverParameter(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
got, err := c.ListIssues(t.Context(), "", "milestone=Old", "Next")
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
if len(got) != 1 || got[0].Milestone != "Old" {
|
|
t.Fatalf("got = %#v, want exactly one Old-milestone issue", got)
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssues_CustomQueryEmptyMilestoneKeySuppressesParameterFallback(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
// Rule 1: an empty milestone= key in customQuery wins outright (does not
|
|
// fall through to the milestone parameter), matching the live-path fold.
|
|
got, err := c.ListIssues(t.Context(), "", "milestone=", "Next")
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
if len(got) != 4 {
|
|
t.Fatalf("len(got) = %d, want 4 (empty milestone key means unfiltered)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssues_HandwrittenCustomQueryUsesFirstMilestoneValue(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
got, err := c.ListIssues(t.Context(), "", "milestone=Old&milestone=Next", "")
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
if len(got) != 1 || got[0].Milestone != "Old" {
|
|
t.Fatalf("got = %#v, want exactly one Old-milestone issue (first value wins)", got)
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssues_FilterMilestoneFallbackWhenNonEmpty(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
got, err := c.ListIssues(t.Context(), "milestone=Old", "", "")
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
if len(got) != 1 || got[0].Milestone != "Old" {
|
|
t.Fatalf("got = %#v, want exactly one Old-milestone issue", got)
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssues_FilterMilestoneEmptyValueIsIgnored(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
got, err := c.ListIssues(t.Context(), "milestone=", "", "")
|
|
if err != nil {
|
|
t.Fatalf("ListIssues() error = %v", err)
|
|
}
|
|
if len(got) != 4 {
|
|
t.Fatalf("len(got) = %d, want 4 (empty filter milestone value is not adopted)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssuesPaged_PagesOverFilteredSortedResult(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
page, err := c.ListIssuesPaged(t.Context(), "", "", "", 1, 2)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if page.TotalCount != 4 {
|
|
t.Errorf("TotalCount = %d, want 4", page.TotalCount)
|
|
}
|
|
if len(page.Issues) != 2 || page.Issues[0].IID != 1 || page.Issues[1].IID != 2 {
|
|
t.Fatalf("page 1 issues = %#v, want alpha#1, alpha#2", page.Issues)
|
|
}
|
|
|
|
page2, err := c.ListIssuesPaged(t.Context(), "", "", "", 2, 2)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if page2.TotalCount != 4 {
|
|
t.Errorf("TotalCount = %d, want 4", page2.TotalCount)
|
|
}
|
|
if len(page2.Issues) != 2 || page2.Issues[0].IID != 3 || page2.Issues[1].ProjectPath != "acme/beta" {
|
|
t.Fatalf("page 2 issues = %#v, want alpha#3, beta#1", page2.Issues)
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssuesPaged_PageBeyondEndYieldsEmptyWithSameTotal(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
page, err := c.ListIssuesPaged(t.Context(), "", "", "", 5, 2)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if page.TotalCount != 4 {
|
|
t.Errorf("TotalCount = %d, want 4", page.TotalCount)
|
|
}
|
|
if len(page.Issues) != 0 {
|
|
t.Errorf("len(Issues) = %d, want 0", len(page.Issues))
|
|
}
|
|
if page.Page != 5 || page.PerPage != 2 {
|
|
t.Errorf("Page/PerPage = %d/%d, want 5/2", page.Page, page.PerPage)
|
|
}
|
|
}
|
|
|
|
// Scenario 16: 30 seeded issues, 26 in milestone "Next", per_page=25 pages
|
|
// the filtered set (not the unfiltered 30), with TotalCount staying at the
|
|
// filtered count on every page including the beyond-end one.
|
|
func TestMockClient_ListIssuesPaged_PagesOverFilteredMilestoneSetAcrossMultiplePages(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
for i := 1; i <= 26; i++ {
|
|
c.SeedIssue("acme/repo", &Issue{IID: i, ProjectPath: "acme/repo", Milestone: "Next"})
|
|
}
|
|
for i := 27; i <= 30; i++ {
|
|
c.SeedIssue("acme/repo", &Issue{IID: i, ProjectPath: "acme/repo", Milestone: "Old"})
|
|
}
|
|
|
|
page1, err := c.ListIssuesPaged(t.Context(), "", "", "Next", 1, 25)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if page1.TotalCount != 26 {
|
|
t.Errorf("page1 TotalCount = %d, want 26", page1.TotalCount)
|
|
}
|
|
if len(page1.Issues) != 25 {
|
|
t.Errorf("page1 len(Issues) = %d, want 25", len(page1.Issues))
|
|
}
|
|
|
|
page2, err := c.ListIssuesPaged(t.Context(), "", "", "Next", 2, 25)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if page2.TotalCount != 26 {
|
|
t.Errorf("page2 TotalCount = %d, want 26", page2.TotalCount)
|
|
}
|
|
if len(page2.Issues) != 1 {
|
|
t.Errorf("page2 len(Issues) = %d, want 1", len(page2.Issues))
|
|
}
|
|
|
|
page3, err := c.ListIssuesPaged(t.Context(), "", "", "Next", 3, 25)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if page3.TotalCount != 26 {
|
|
t.Errorf("page3 TotalCount = %d, want 26", page3.TotalCount)
|
|
}
|
|
if len(page3.Issues) != 0 {
|
|
t.Errorf("page3 len(Issues) = %d, want 0", len(page3.Issues))
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssuesPaged_NegativePageNormalisedToOne(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
page, err := c.ListIssuesPaged(t.Context(), "", "", "", -3, 2)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if page.Page != 1 {
|
|
t.Errorf("Page = %d, want normalised to 1", page.Page)
|
|
}
|
|
if len(page.Issues) != 2 || page.Issues[0].IID != 1 {
|
|
t.Fatalf("issues = %#v, want the first page's worth", page.Issues)
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssuesPaged_NonPositivePerPageReturnsNoIssues(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
page, err := c.ListIssuesPaged(t.Context(), "", "", "", 1, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if len(page.Issues) != 0 {
|
|
t.Errorf("len(Issues) = %d, want 0", len(page.Issues))
|
|
}
|
|
if page.TotalCount != 4 {
|
|
t.Errorf("TotalCount = %d, want 4 (full filtered count, even with no results)", page.TotalCount)
|
|
}
|
|
if page.PerPage != 0 {
|
|
t.Errorf("PerPage = %d, want normalised to 0", page.PerPage)
|
|
}
|
|
|
|
negative, err := c.ListIssuesPaged(t.Context(), "", "", "", 1, -5)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if len(negative.Issues) != 0 {
|
|
t.Errorf("len(Issues) = %d, want 0", len(negative.Issues))
|
|
}
|
|
if negative.PerPage != 0 {
|
|
t.Errorf("PerPage = %d, want normalised to 0", negative.PerPage)
|
|
}
|
|
}
|
|
|
|
// The HTTP layer's paginationFromQuery only floors page/perPage, it never
|
|
// caps the upper bound (unlike PATClient's clampSearchPage), so an
|
|
// out-of-range per_page can reach the mock directly. ListIssuesPaged must
|
|
// not overflow its start/end arithmetic and panic on the resulting slice.
|
|
// Spec (spec.md:736-747) authorizes only two normalisations — page < 1 and
|
|
// perPage < 1 — so an extreme-but-positive perPage is echoed back as given,
|
|
// not clamped to a page size the spec never mentions.
|
|
func TestMockClient_ListIssuesPaged_ExtremePerPageDoesNotOverflowOrPanic(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
first, err := c.ListIssuesPaged(t.Context(), "", "", "", 1, math.MaxInt)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if len(first.Issues) != 4 {
|
|
t.Errorf("len(Issues) = %d, want all 4 on page 1", len(first.Issues))
|
|
}
|
|
if first.PerPage != math.MaxInt {
|
|
t.Errorf("PerPage = %d, want echoed back as math.MaxInt, not clamped", first.PerPage)
|
|
}
|
|
if first.TotalCount != 4 {
|
|
t.Errorf("TotalCount = %d, want 4", first.TotalCount)
|
|
}
|
|
|
|
page, err := c.ListIssuesPaged(t.Context(), "", "", "", 2, math.MaxInt)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if page.TotalCount != 4 {
|
|
t.Errorf("TotalCount = %d, want 4", page.TotalCount)
|
|
}
|
|
if len(page.Issues) != 0 {
|
|
t.Errorf("len(Issues) = %d, want 0 (page 2 is beyond the end of the unfiltered set)", len(page.Issues))
|
|
}
|
|
if page.PerPage != math.MaxInt {
|
|
t.Errorf("PerPage = %d, want echoed back as math.MaxInt, not clamped", page.PerPage)
|
|
}
|
|
}
|
|
|
|
func TestMockClient_ListIssuesPaged_ForwardsMilestoneWithoutResolvingTwice(t *testing.T) {
|
|
c := NewMockClient(DefaultHost)
|
|
seedMilestoneIssues(t, c)
|
|
|
|
page, err := c.ListIssuesPaged(t.Context(), "", "", "Next", 1, 10)
|
|
if err != nil {
|
|
t.Fatalf("ListIssuesPaged() error = %v", err)
|
|
}
|
|
if page.TotalCount != 2 {
|
|
t.Errorf("TotalCount = %d, want 2", page.TotalCount)
|
|
}
|
|
for _, issue := range page.Issues {
|
|
if issue.Milestone != "Next" {
|
|
t.Errorf("issue %+v has milestone %q, want Next", issue, issue.Milestone)
|
|
}
|
|
}
|
|
}
|