Files
kandev/apps/backend/internal/gitlab/pat_client_test.go
T

708 lines
24 KiB
Go

package gitlab
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
)
func TestPATClientProviderActionContract(t *testing.T) {
var _ Client = (*PATClient)(nil)
}
// newTestServer wires a handler under /api/v4/* and returns the host URL.
func newTestServer(t *testing.T, handler http.Handler) (host string, teardown func()) {
t.Helper()
mux := http.NewServeMux()
mux.Handle("/api/v4/", http.StripPrefix("/api/v4", handler))
srv := httptest.NewServer(mux)
return srv.URL, srv.Close
}
func TestPATClient_GetAuthenticatedUser(t *testing.T) {
calls := 0
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/user" {
t.Errorf("path = %q, want /user", r.URL.Path)
}
if r.Header.Get("PRIVATE-TOKEN") != "tok" {
t.Errorf("PRIVATE-TOKEN header = %q, want tok", r.Header.Get("PRIVATE-TOKEN"))
}
_ = json.NewEncoder(w).Encode(map[string]any{"username": "alice"})
}))
defer stop()
c := NewPATClient(host, "tok")
user, err := c.GetAuthenticatedUser(context.Background())
if err != nil {
t.Fatalf("err = %v", err)
}
if user != "alice" {
t.Fatalf("user = %q, want alice", user)
}
// Cached on second call — the server must not be hit again. A non-
// caching implementation would still pass an err-only assertion, so
// the request counter is what actually proves the cache.
if _, err := c.GetAuthenticatedUser(context.Background()); err != nil {
t.Fatalf("cached call err = %v", err)
}
if calls != 1 {
t.Errorf("server called %d times, want 1 (second call must hit cache)", calls)
}
}
func TestPATClient_AnonymousReadOmitsPrivateToken(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("PRIVATE-TOKEN"); got != "" {
t.Fatalf("PRIVATE-TOKEN = %q, want absent", got)
}
_, _ = w.Write([]byte(`[{"name":"main"}]`))
}))
t.Cleanup(stop)
branches, err := NewPATClient(host, "").ListProjectBranches(t.Context(), "group/project")
if err != nil {
t.Fatalf("ListProjectBranches() error = %v", err)
}
if len(branches) != 1 || branches[0].Name != "main" {
t.Fatalf("branches = %#v, want main", branches)
}
}
func TestPATClient_GetAuthenticatedUser_AuthFailure(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"401 Unauthorized"}`))
}))
defer stop()
c := NewPATClient(host, "bad")
_, err := c.GetAuthenticatedUser(context.Background())
if err == nil {
t.Fatal("expected error")
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want *APIError", err)
}
if apiErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", apiErr.StatusCode)
}
}
func TestPATClient_GetMR(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// EscapedPath preserves %2F; r.URL.Path is the decoded form.
if got, want := r.URL.EscapedPath(), "/projects/group%2Fproject/merge_requests/42"; got != want {
t.Errorf("escaped path = %q, want %q (URL-encoded slash)", got, want)
}
_ = json.NewEncoder(w).Encode(rawMR{
ID: 1, IID: 42, ProjectID: 7,
Title: "Add feature", State: "opened",
SourceBranch: "feat/x", TargetBranch: "main",
SHA: "abc123",
Author: rawUser{Username: "alice"},
References: struct {
Full string `json:"full"`
}{Full: "group/project!42"},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
})
}))
defer stop()
c := NewPATClient(host, "tok")
mr, err := c.GetMR(context.Background(), "group/project", 42)
if err != nil {
t.Fatalf("err = %v", err)
}
if mr.IID != 42 {
t.Errorf("iid = %d, want 42", mr.IID)
}
if mr.State != "open" {
t.Errorf("state = %q, want open (normalized from opened)", mr.State)
}
if mr.HeadBranch != "feat/x" || mr.BaseBranch != "main" {
t.Errorf("branches = %q/%q, want feat/x/main", mr.HeadBranch, mr.BaseBranch)
}
if mr.AuthorUsername != "alice" {
t.Errorf("author = %q, want alice", mr.AuthorUsername)
}
}
func TestPATClient_GetMRHydratesForkSourceProjectByID(t *testing.T) {
var sourceProjectRequested bool
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.EscapedPath() {
case "/projects/group%2Fproject/merge_requests/4":
// Fork responses can contain only the source and target project
// IDs. The embedded source_project object is not required by the
// GitLab Merge Requests API.
_, _ = w.Write([]byte(`{"id":400,"iid":4,"project_id":99,"title":"Contribution","state":"opened","web_url":"https://gitlab.example.com/group/project/-/merge_requests/4","source_branch":"feature/remote","target_branch":"main","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","references":{"full":"group/project!4"},"source_project_id":42,"target_project_id":99,"allow_collaboration":false}`))
case "/projects/42":
sourceProjectRequested = true
_, _ = w.Write([]byte(`{"id":42,"path_with_namespace":"contributor/project","http_url_to_repo":"https://gitlab.example.com/contributor/project.git","default_branch":"main"}`))
default:
http.NotFound(w, r)
}
}))
defer stop()
mr, err := NewPATClient(host, "token").GetMR(context.Background(), "group/project", 4)
if err != nil {
t.Fatalf("GetMR() error = %v", err)
}
if !sourceProjectRequested {
t.Fatal("GetMR() did not resolve the differing source project ID")
}
if mr.SourceProjectID != 42 || mr.SourceProjectPath != "contributor/project" {
t.Fatalf("source identity = (%d, %q), want (42, contributor/project)", mr.SourceProjectID, mr.SourceProjectPath)
}
if mr.SourceProjectRemoteURL != "https://gitlab.example.com/contributor/project.git" {
t.Fatalf("source remote URL = %q, want hydrated source URL", mr.SourceProjectRemoteURL)
}
if mr.TargetProjectID != 99 || mr.TargetProjectPath != "group/project" {
t.Fatalf("target identity = (%d, %q), want (99, group/project)", mr.TargetProjectID, mr.TargetProjectPath)
}
}
func TestPATClientSetMRLabelsThenFeedbackHydratesReplacement(t *testing.T) {
labels := []string{"old"}
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
var body struct {
Labels string `json:"labels"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
labels = strings.Split(body.Labels, ",")
w.WriteHeader(http.StatusOK)
return
}
if strings.HasSuffix(r.URL.Path, "/approvals") {
_, _ = w.Write([]byte(`{}`))
return
}
if strings.HasSuffix(r.URL.Path, "/discussions") || strings.Contains(r.URL.Path, "/pipelines") {
_, _ = w.Write([]byte(`[]`))
return
}
_ = json.NewEncoder(w).Encode(rawMR{IID: 7, Labels: labels})
}))
defer stop()
client := NewPATClient(host, "tok")
if err := client.SetMRLabels(t.Context(), "team/repo", 7, []string{"bug", "backend"}); err != nil {
t.Fatalf("set labels: %v", err)
}
feedback, err := client.GetMRFeedback(t.Context(), "team/repo", 7)
if err != nil {
t.Fatalf("feedback: %v", err)
}
if got := feedback.MR.Labels; len(got) != 2 || got[0] != "bug" || got[1] != "backend" {
t.Fatalf("labels = %#v, want replacement", got)
}
}
func TestPATClient_FindMRByBranch_Empty(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
mr, err := c.FindMRByBranch(context.Background(), "g/p", "missing")
if err != nil {
t.Fatalf("err = %v", err)
}
if mr != nil {
t.Fatalf("mr = %+v, want nil", mr)
}
}
func TestPATClient_CreateMR_DraftPrefix(t *testing.T) {
var receivedBody map[string]any
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
_ = json.NewEncoder(w).Encode(rawMR{IID: 7, Title: "Draft: feat", State: "opened"})
}))
defer stop()
c := NewPATClient(host, "tok")
mr, err := c.CreateMR(context.Background(), "g/p", "feat", "main", "feat", "body", true)
if err != nil {
t.Fatalf("err = %v", err)
}
if mr.IID != 7 {
t.Errorf("iid = %d, want 7", mr.IID)
}
if got, want := receivedBody["title"], "Draft: feat"; got != want {
t.Errorf("title sent = %v, want %q", got, want)
}
}
func TestPATClient_CreateMR_NoDoubleDraftPrefix(t *testing.T) {
var receivedBody map[string]any
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
_ = json.NewEncoder(w).Encode(rawMR{IID: 8, State: "opened"})
}))
defer stop()
c := NewPATClient(host, "tok")
if _, err := c.CreateMR(context.Background(), "g/p", "feat", "main", "Draft: already", "", true); err != nil {
t.Fatalf("err = %v", err)
}
if got := receivedBody["title"].(string); strings.Count(got, "Draft:") != 1 {
t.Errorf("title = %q, expected single Draft: prefix", got)
}
}
func TestPATClient_ResolveDiscussion_UsesPUT(t *testing.T) {
called := false
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.Method != http.MethodPut {
t.Errorf("method = %s, want PUT", r.Method)
}
if !strings.Contains(r.URL.RawQuery, "resolved=true") {
t.Errorf("query = %q, want resolved=true", r.URL.RawQuery)
}
w.WriteHeader(http.StatusOK)
}))
defer stop()
c := NewPATClient(host, "tok")
if err := c.ResolveMRDiscussion(context.Background(), "g/p", 1, "abc123"); err != nil {
t.Fatalf("err = %v", err)
}
if !called {
t.Fatal("server was not called")
}
}
func TestPATClient_ListMRDiscussions_ConvertsThread(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{
"id": "thread-1",
"notes": [
{"id":1,"body":"first","resolvable":true,"resolved":false,"author":{"username":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
{"id":2,"body":"reply","author":{"username":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
]
}]`))
}))
defer stop()
c := NewPATClient(host, "tok")
d, err := c.ListMRDiscussions(context.Background(), "g/p", 1, nil)
if err != nil {
t.Fatalf("err = %v", err)
}
if len(d) != 1 {
t.Fatalf("got %d discussions, want 1", len(d))
}
if !d[0].Resolvable {
t.Error("resolvable = false, want true (taken from first note)")
}
if d[0].Resolved {
t.Error("resolved = true, want false")
}
if len(d[0].Notes) != 2 {
t.Fatalf("notes = %d, want 2", len(d[0].Notes))
}
wantUpdate := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)
if !d[0].UpdatedAt.Equal(wantUpdate) {
t.Errorf("updated_at = %v, want %v (latest reply)", d[0].UpdatedAt, wantUpdate)
}
}
func TestPATClient_ListMRDiscussions_FiltersBySince(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[
{"id":"old","notes":[{"id":1,"created_at":"2026-04-01T00:00:00Z","updated_at":"2026-04-01T00:00:00Z"}]},
{"id":"new","notes":[{"id":2,"created_at":"2026-05-15T00:00:00Z","updated_at":"2026-05-15T00:00:00Z"}]}
]`))
}))
defer stop()
since := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
c := NewPATClient(host, "tok")
d, err := c.ListMRDiscussions(context.Background(), "g/p", 1, &since)
if err != nil {
t.Fatalf("err = %v", err)
}
if len(d) != 1 || d[0].ID != "new" {
t.Fatalf("got %#v, want only the 'new' discussion", d)
}
}
func TestPATClient_SearchMRsPaged_HonoursTotalHeader(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Total", "153")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page == 1 {
_, _ = w.Write([]byte(`[{"iid":1,"state":"opened","author":{"username":"a"},"references":{"full":"g/p!1"}}]`))
return
}
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
page, err := c.SearchMRsPaged(context.Background(), "", "", 1, 25)
if err != nil {
t.Fatalf("err = %v", err)
}
if page.TotalCount != 153 {
t.Errorf("total = %d, want 153", page.TotalCount)
}
if len(page.MRs) != 1 {
t.Errorf("mrs = %d, want 1", len(page.MRs))
}
}
// The /gitlab page's "Assigned" tab translates through
// translateUserSearchFilter into a `scope=assigned_to_me` filter that
// SearchMRsPaged must place on the wire as-is — previously a bare
// "assigned_to_me" filter ended up as an empty-value key and the page
// served the global, unscoped listing.
func TestPATClient_SearchMRsPaged_ScopeAssignedToMe(t *testing.T) {
var receivedQuery url.Values
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedQuery = r.URL.Query()
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
if _, err := c.SearchMRsPaged(context.Background(), "scope=assigned_to_me", "", 1, 25); err != nil {
t.Fatalf("err = %v", err)
}
if got := receivedQuery.Get("scope"); got != "assigned_to_me" {
t.Errorf("scope = %q, want assigned_to_me", got)
}
if got := receivedQuery.Get("state"); got != "opened" {
t.Errorf("state = %q, want opened (default seeded by buildMRSearchQuery)", got)
}
}
// The "Review requested" tab needs the resolved username because GitLab
// has no scope=review_requested — translateUserSearchFilter emits
// reviewer_username=<me>&scope=all and both keys must land on the wire.
func TestPATClient_SearchMRsPaged_ReviewerUsername(t *testing.T) {
var receivedQuery url.Values
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedQuery = r.URL.Query()
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
if _, err := c.SearchMRsPaged(context.Background(), "reviewer_username=alice&scope=all", "", 1, 25); err != nil {
t.Fatalf("err = %v", err)
}
if got := receivedQuery.Get("reviewer_username"); got != "alice" {
t.Errorf("reviewer_username = %q, want alice", got)
}
if got := receivedQuery.Get("scope"); got != "all" {
t.Errorf("scope = %q, want all (user-supplied value overrides default)", got)
}
}
// Issues counterpart — same regression coverage on the /issues endpoint.
func TestPATClient_ListIssuesPaged_ScopeAssignedToMe(t *testing.T) {
var receivedPath string
var receivedQuery url.Values
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
receivedQuery = r.URL.Query()
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
if _, err := c.ListIssuesPaged(context.Background(), "scope=assigned_to_me", "", 1, 25); err != nil {
t.Fatalf("err = %v", err)
}
if receivedPath != "/issues" {
t.Errorf("path = %q, want /issues", receivedPath)
}
if got := receivedQuery.Get("scope"); got != "assigned_to_me" {
t.Errorf("scope = %q, want assigned_to_me", got)
}
if got := receivedQuery.Get("state"); got != "opened" {
t.Errorf("state = %q, want opened", got)
}
}
func TestPATClient_ListIssuesPaged_HonoursTotalHeader(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// X-Total drives pagination; the body returns a small slice so the
// test verifies header + body wire up independently.
w.Header().Set("X-Total", "87")
if r.URL.Path != "/issues" {
// newTestServer strips the /api/v4 prefix before dispatch.
t.Errorf("path = %q, want /issues", r.URL.Path)
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
perPage := r.URL.Query().Get("per_page")
if perPage != "20" {
t.Errorf("per_page = %q, want 20 — paging args must reach the server", perPage)
}
if page == 2 {
_, _ = w.Write([]byte(`[{"iid":42,"state":"opened","author":{"username":"alice"},"references":{"full":"g/p#42"}}]`))
return
}
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
page, err := c.ListIssuesPaged(context.Background(), "", "", 2, 20)
if err != nil {
t.Fatalf("err = %v", err)
}
if page.TotalCount != 87 {
t.Errorf("total = %d, want 87 (X-Total header)", page.TotalCount)
}
if page.Page != 2 || page.PerPage != 20 {
t.Errorf("page/perPage = %d/%d, want 2/20 (echoed back)", page.Page, page.PerPage)
}
if len(page.Issues) != 1 || page.Issues[0].IID != 42 {
t.Errorf("issues = %+v, want one issue with IID 42", page.Issues)
}
}
func TestParseNextLink(t *testing.T) {
apiBase := "https://gitlab.example.com/api/v4"
cases := []struct {
name string
header string
want string
}{
{"empty", "", ""},
{"next", `<https://gitlab.example.com/api/v4/issues?page=2>; rel="next"`, "/issues?page=2"},
{"prev-only", `<https://gitlab.example.com/api/v4/issues?page=2>; rel="prev"`, ""},
{"next-then-last", `<https://gitlab.example.com/api/v4/issues?page=3>; rel="next", <https://gitlab.example.com/api/v4/issues?page=10>; rel="last"`, "/issues?page=3"},
// Cross-host or wrong-base URLs are dropped rather than returned
// raw — passing a bare absolute URL back to get() would double-
// prefix it with host + apiPathPrefix.
{"foreign-host-dropped", `<https://other.example.com/api/v4/issues?page=2>; rel="next"`, ""},
{"no-apibase-dropped", `</issues?page=2>; rel="next"`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseNextLink(tc.header, apiBase)
if got != tc.want {
t.Errorf("parseNextLink(%q) = %q, want %q", tc.header, got, tc.want)
}
})
}
}
func TestPATClient_IsAuthenticated_PropagatesNonAuthErrors(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
}))
defer stop()
c := NewPATClient(host, "tok")
ok, err := c.IsAuthenticated(context.Background())
if err == nil {
t.Fatal("expected error for 500 response (transport failure must not be reported as 'not authenticated')")
}
if ok {
t.Error("authenticated = true on 500, want false")
}
}
func TestPATClient_IsAuthenticated_401IsClean(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer stop()
c := NewPATClient(host, "bad")
ok, err := c.IsAuthenticated(context.Background())
if err != nil {
t.Errorf("err = %v, want nil for 401 (it's a clean 'not authenticated' signal)", err)
}
if ok {
t.Error("authenticated = true on 401, want false")
}
}
// Regression: if a token gets revoked upstream, IsAuthenticated must surface
// the failure even though the username was previously cached. The previous
// implementation called GetAuthenticatedUser, which short-circuited on the
// cache and made revoked tokens appear connected forever.
func TestPATClient_IsAuthenticated_DoesNotTrustStaleCache(t *testing.T) {
calls := 0
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
if calls == 1 {
_, _ = w.Write([]byte(`{"username":"alice"}`))
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer stop()
c := NewPATClient(host, "tok")
// Warm the cache.
if _, err := c.GetAuthenticatedUser(context.Background()); err != nil {
t.Fatalf("warmup err = %v", err)
}
ok, err := c.IsAuthenticated(context.Background())
if err != nil {
t.Errorf("err = %v, want nil for 401", err)
}
if ok {
t.Error("authenticated = true after upstream revoked token, want false (cache must not mask revocation)")
}
}
// SearchGroupProjects must %2F-encode slashes in subgroup paths so the
// resulting URL is /groups/acme%2Fteam/projects, not /groups/acme/team/projects
// (which GitLab routes to the parent only and returns 404 for). Without this
// regression test a future refactor that switched groupRef back to a bare
// url.PathEscape would silently break subgroup lookups.
func TestPATClient_SearchGroupProjects_EncodesSubgroupSlash(t *testing.T) {
var receivedPath string
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// EscapedPath() preserves %2F; r.URL.Path is the decoded form.
receivedPath = r.URL.EscapedPath()
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
if _, err := c.SearchGroupProjects(context.Background(), "acme/team", "", 5); err != nil {
t.Fatalf("err = %v", err)
}
// newTestServer mounts handlers under /api/v4/* via StripPrefix, so the
// handler sees the path without the /api/v4 prefix.
want := "/groups/acme%2Fteam/projects"
if receivedPath != want {
t.Errorf("escaped path = %q, want %q (subgroup slash must be %%2F-encoded)", receivedPath, want)
}
}
func TestNormalizeMRState(t *testing.T) {
cases := map[string]string{
"opened": "open",
"closed": "closed",
"merged": "merged",
"locked": "locked",
"": "",
}
for in, want := range cases {
if got := normalizeMRState(in); got != want {
t.Errorf("normalizeMRState(%q) = %q, want %q", in, got, want)
}
}
}
func TestSplitFullReference(t *testing.T) {
// projectPath is the full namespace-qualified path so callers can pass
// it straight to projectRef. namespace is everything before the final
// "/" — same shape as Project.Namespace.
cases := []struct {
in string
wantNamespace string
wantPath string
}{
{"group/project!42", "group", "group/project"},
{"group/sub/project!7", "group/sub", "group/sub/project"},
{"group/project#10", "group", "group/project"},
{"noslash", "", ""},
{"", "", ""},
}
for _, tc := range cases {
ns, p := splitFullReference(tc.in)
if ns != tc.wantNamespace || p != tc.wantPath {
t.Errorf("splitFullReference(%q) = (%q, %q), want (%q, %q)", tc.in, ns, p, tc.wantNamespace, tc.wantPath)
}
}
}
func TestSummarizePipelines(t *testing.T) {
cases := []struct {
name string
input []Pipeline
state string
expectsTotal bool
}{
{"empty", nil, "", false},
{"success", []Pipeline{{Status: "success", JobsTotal: 3, JobsPassing: 3}}, "success", true},
{"failed", []Pipeline{{Status: "failed"}}, "failure", false},
{"running", []Pipeline{{Status: "running"}}, "pending", false},
{"skipped-suppressed", []Pipeline{{Status: pipelineStatusSkipped}}, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
state, total, _ := summarizePipelines(tc.input)
if state != tc.state {
t.Errorf("state = %q, want %q", state, tc.state)
}
if tc.expectsTotal && total == 0 {
t.Error("expected jobs total > 0")
}
})
}
}
func TestSummarizeApprovals(t *testing.T) {
cases := []struct {
have, required int
want string
}{
{0, 0, ""},
{1, 0, "approved"},
{2, 2, "approved"},
{1, 2, "pending"},
{0, 1, "pending"},
}
for _, tc := range cases {
if got := summarizeApprovals(tc.have, tc.required); got != tc.want {
t.Errorf("summarizeApprovals(%d,%d) = %q, want %q", tc.have, tc.required, got, tc.want)
}
}
}
func TestCountDiffLines(t *testing.T) {
diff := "--- a/foo.go\n+++ b/foo.go\n@@ -1,3 +1,3 @@\n-old\n+new1\n+new2\n unchanged\n"
add, del := countDiffLines(diff)
if add != 2 {
t.Errorf("additions = %d, want 2", add)
}
if del != 1 {
t.Errorf("deletions = %d, want 1", del)
}
}
func TestClampSearchPage(t *testing.T) {
cases := []struct{ inP, inPP, wantP, wantPP int }{
{0, 0, 1, defaultPageSize},
{-3, 5, 1, 5},
{2, 250, 2, maxPageSize},
{5, 30, 5, 30},
}
for _, tc := range cases {
p, pp := clampSearchPage(tc.inP, tc.inPP)
if p != tc.wantP || pp != tc.wantPP {
t.Errorf("clamp(%d,%d) = (%d,%d), want (%d,%d)", tc.inP, tc.inPP, p, pp, tc.wantP, tc.wantPP)
}
}
}