package gitlab import ( "encoding/json" "net/url" "testing" ) func TestConvertReviewersPreservesNumericUserID(t *testing.T) { var user rawUser if err := json.Unmarshal([]byte(`{"id":42,"username":"alice","name":"Alice"}`), &user); err != nil { t.Fatalf("unmarshal reviewer: %v", err) } reviewers := convertReviewers([]rawUser{user}) encoded, err := json.Marshal(reviewers) if err != nil { t.Fatalf("marshal reviewers: %v", err) } if got, want := string(encoded), `[{"id":42,"username":"alice","name":"Alice","type":"user"}]`; got != want { t.Fatalf("reviewers JSON = %s, want %s", got, want) } } func TestConvertRawMRHydratesLabels(t *testing.T) { mr := convertRawMR(&rawMR{Labels: []string{"bug", "backend"}}) if got, want := mr.Labels, []string{"bug", "backend"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { t.Fatalf("labels = %#v, want %#v", got, want) } } func TestConvertRawMR_DetailedMergeStatus(t *testing.T) { // Present (GitLab 15.6+). mr := convertRawMR(&rawMR{DetailedMergeStatus: "mergeable", BlockingDiscussionsResolved: true}) if mr.DetailedMergeStatus != "mergeable" { t.Errorf("DetailedMergeStatus = %q, want mergeable", mr.DetailedMergeStatus) } if !mr.BlockingDiscussionsResolved { t.Errorf("BlockingDiscussionsResolved = false, want true") } // Absent (pre-15.6 self-managed host) — must not fabricate a value. old := convertRawMR(&rawMR{MergeStatus: "can_be_merged"}) if old.DetailedMergeStatus != "" { t.Errorf("DetailedMergeStatus = %q, want empty when the API omits it", old.DetailedMergeStatus) } } func TestCountUnapprovedReviewers(t *testing.T) { reviewers := []MRReviewer{{Username: "alice"}, {Username: "bob"}, {Username: "carol"}} approvals := []MRApproval{{Username: "bob"}} if got := countUnapprovedReviewers(reviewers, approvals); got != 2 { t.Errorf("countUnapprovedReviewers() = %d, want 2 (alice, carol)", got) } if got := countUnapprovedReviewers(nil, approvals); got != 0 { t.Errorf("countUnapprovedReviewers(no reviewers) = %d, want 0", got) } allApproved := []MRApproval{{Username: "alice"}, {Username: "bob"}, {Username: "carol"}} if got := countUnapprovedReviewers(reviewers, allApproved); got != 0 { t.Errorf("countUnapprovedReviewers(all approved) = %d, want 0", got) } } // User-supplied filter keys must override defaults seeded by the caller. // The previous implementation used values.Add(), which kept the default // alongside the user value (e.g. state=opened&state=closed) and GitLab // silently honoured the first one. func TestAppendFilter_UserKeysOverrideDefaults(t *testing.T) { values := url.Values{} values.Set("state", "opened") values.Set("scope", "all") appendFilter(values, "state=closed&labels=bug") if got := values.Get("state"); got != "closed" { t.Errorf("state = %q, want closed (user filter must override default)", got) } if got := values["state"]; len(got) != 1 { t.Errorf("state values = %v, want length 1 (defaults must be cleared before re-adding)", got) } if got := values.Get("labels"); got != "bug" { t.Errorf("labels = %q, want bug", got) } if got := values.Get("scope"); got != "all" { t.Errorf("scope = %q, want all (untouched keys keep their default)", got) } } func TestAppendFilter_MultiValueOverride(t *testing.T) { values := url.Values{} values.Set("labels", "default") appendFilter(values, "labels=a&labels=b") got := values["labels"] if len(got) != 2 || got[0] != "a" || got[1] != "b" { t.Errorf("labels = %v, want [a b] (default cleared then both user values appended)", got) } } func TestAppendFilter_UnparseableIgnored(t *testing.T) { values := url.Values{} values.Set("state", "opened") appendFilter(values, "%bad%") if got := values.Get("state"); got != "opened" { t.Errorf("state = %q, want opened (unparseable filter must not nuke defaults)", got) } } // translateUserSearchFilter is the seam that turns the /gitlab page's tab // values (assigned_to_me / created_by_me / review_requested) into proper // GitLab API filter strings. Before this helper existed, the controller // passed the bare tab tokens straight to appendFilter, which parsed them as // keys with empty values — the filter contributed nothing and the page // served the global, unscoped listing. func TestTranslateUserSearchFilter(t *testing.T) { cases := []struct { name string token string username string want string }{ {"assigned_to_me", "assigned_to_me", "", "scope=assigned_to_me"}, {"created_by_me", "created_by_me", "", "scope=created_by_me"}, {"review_requested_with_user", "review_requested", "alice", "reviewer_username=alice&scope=all"}, // Empty username means the controller failed to resolve /user; the // translator must refuse so the caller can surface the error instead // of silently falling back to the global listing. {"review_requested_without_user", "review_requested", "", ""}, // Already in key=value form — power-user passthrough must not be // rewritten. Verified end-to-end by TestAppendFilter_*. {"raw_key_value_passthrough", "labels=bug", "", ""}, {"raw_key_value_with_amp", "labels=bug&state=closed", "", ""}, // Unknown token — leave it to appendFilter to ignore. {"unknown_token", "mystery", "", ""}, {"empty", "", "", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := translateUserSearchFilter(tc.token, tc.username) if got != tc.want { t.Errorf("translateUserSearchFilter(%q, %q) = %q, want %q", tc.token, tc.username, got, tc.want) } }) } } // Usernames with characters that need URL escaping (rare on GitLab but // possible on self-managed instances configured to allow them) must not // land on the wire unescaped — the resulting query string would be // ambiguous and could either break parsing or worse, accidentally inject // extra params. func TestTranslateUserSearchFilter_EscapesUsername(t *testing.T) { got := translateUserSearchFilter("review_requested", "a&b=c") want := "reviewer_username=a%26b%3Dc&scope=all" if got != want { t.Errorf("got = %q, want %q (username must be URL-escaped)", got, want) } }