708 lines
24 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|