Files
kandev/apps/backend/internal/gitlab/mock_client_issues_test.go
Henry ffec4aade9 feat(gitlab): filter issues by milestone (#3278)
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>
2026-09-02 16:55:31 +00:00

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)
}
}
}