package github import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" ) // controllerReadStub overrides the read methods the list/search endpoints use. // The embedded *stubClient supplies no-op defaults for the rest of Client. type controllerReadStub struct { *stubClient orgs []GitHubOrg orgsErr error orgRepos []GitHubRepo orgReposErr error branches []RepoBranch branchesErr error prPage *PRSearchPage prPageErr error issuePage *IssueSearchPage issueErr error feedback *PRFeedback feedbackErr error prStatus *PRStatus statusErr error } func newControllerReadStub() *controllerReadStub { return &controllerReadStub{stubClient: &stubClient{}} } func (s *controllerReadStub) ListUserOrgs(context.Context) ([]GitHubOrg, error) { return s.orgs, s.orgsErr } func (s *controllerReadStub) SearchOrgRepos(context.Context, string, string, int) ([]GitHubRepo, error) { return s.orgRepos, s.orgReposErr } func (s *controllerReadStub) ListRepoBranches(context.Context, string, string) ([]RepoBranch, error) { return s.branches, s.branchesErr } func (s *controllerReadStub) SearchPRsPaged( context.Context, string, string, int, int, ) (*PRSearchPage, error) { if s.prPageErr != nil { return nil, s.prPageErr } if s.prPage != nil { return s.prPage, nil } return &PRSearchPage{PRs: []*PR{}}, nil } func (s *controllerReadStub) ListIssuesPaged( context.Context, string, string, int, int, ) (*IssueSearchPage, error) { if s.issueErr != nil { return nil, s.issueErr } if s.issuePage != nil { return s.issuePage, nil } return &IssueSearchPage{Issues: []*Issue{}}, nil } func (s *controllerReadStub) GetPRFeedback( context.Context, string, string, int, ) (*PRFeedback, error) { return s.feedback, s.feedbackErr } func (s *controllerReadStub) GetPRStatus(context.Context, string, string, int) (*PRStatus, error) { return s.prStatus, s.statusErr } // doControllerRequest issues a request through the wired router and returns the // recorder so callers can assert both status and decoded body. func doControllerRequest(router http.Handler, method, target string) *httptest.ResponseRecorder { request := httptest.NewRequest(method, target, nil) response := httptest.NewRecorder() router.ServeHTTP(response, request) return response } // decodeControllerBody decodes a JSON response body into out. func decodeControllerBody(t *testing.T, response *httptest.ResponseRecorder, out any) { t.Helper() if err := json.Unmarshal(response.Body.Bytes(), out); err != nil { t.Fatalf("decode body %q: %v", response.Body.String(), err) } } // The authenticated user is prepended as a pseudo-org so the picker can offer // personal repositories alongside real organizations. func TestController_ListUserOrgs(t *testing.T) { client := newControllerReadStub() client.orgs = []GitHubOrg{{Login: "acme", AvatarURL: "https://avatars/acme"}} router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/orgs") if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body) } var body struct { Orgs []GitHubOrg `json:"orgs"` } decodeControllerBody(t, response, &body) if len(body.Orgs) != 2 { t.Fatalf("orgs = %#v, want the pseudo-org plus acme", body.Orgs) } if body.Orgs[0] != (GitHubOrg{Login: "test-user"}) { t.Errorf("orgs[0] = %#v, want the authenticated user first", body.Orgs[0]) } if body.Orgs[1] != (GitHubOrg{Login: "acme", AvatarURL: "https://avatars/acme"}) { t.Errorf("orgs[1] = %#v", body.Orgs[1]) } } func TestController_ListUserOrgs_UpstreamFailureIs500(t *testing.T) { client := newControllerReadStub() client.orgsErr = &GitHubAPIError{StatusCode: http.StatusUnauthorized, Endpoint: "/user/orgs"} router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/orgs") if response.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500; body = %s", response.Code, response.Body) } var body struct { Error string `json:"error"` } decodeControllerBody(t, response, &body) if body.Error == "" { t.Error("error body is empty, want the upstream message surfaced") } } func TestController_SearchRepos(t *testing.T) { client := newControllerReadStub() client.orgRepos = []GitHubRepo{ {FullName: "acme/widget", Owner: "acme", Name: "widget", DefaultBranch: "main"}, } router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/repos/search?org=acme&q=wid") if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body) } var body struct { Repos []GitHubRepo `json:"repos"` } decodeControllerBody(t, response, &body) if len(body.Repos) != 1 || body.Repos[0].FullName != "acme/widget" { t.Fatalf("repos = %#v", body.Repos) } if body.Repos[0].DefaultBranch != "main" { t.Errorf("default_branch = %q, want main", body.Repos[0].DefaultBranch) } } func TestController_SearchRepos_OrgIsRequired(t *testing.T) { router, _ := setupControllerTest(newControllerReadStub()) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/repos/search?q=wid") if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body) } var body struct { Error string `json:"error"` } decodeControllerBody(t, response, &body) if body.Error != "org query parameter required" { t.Errorf("error = %q", body.Error) } } func TestController_SearchRepos_UpstreamFailureIs500(t *testing.T) { client := newControllerReadStub() client.orgReposErr = &GitHubAPIError{StatusCode: http.StatusForbidden, Endpoint: "/search/repositories"} router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/repos/search?org=acme") if response.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500; body = %s", response.Code, response.Body) } } func TestController_ListRepoBranches(t *testing.T) { client := newControllerReadStub() client.branches = []RepoBranch{{Name: "main"}, {Name: "release/1.0"}} router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/repos/acme/widget/branches") if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body) } var body struct { Branches []RepoBranch `json:"branches"` } decodeControllerBody(t, response, &body) if len(body.Branches) != 2 || body.Branches[0].Name != "main" || body.Branches[1].Name != "release/1.0" { t.Fatalf("branches = %#v", body.Branches) } } // Upstream status codes must survive to the client so the UI can re-prompt for // auth on a 401 rather than showing an opaque 500. func TestController_ListRepoBranches_PreservesUpstreamStatus(t *testing.T) { cases := []struct { upstream int want int }{ {http.StatusNotFound, http.StatusNotFound}, {http.StatusUnauthorized, http.StatusUnauthorized}, {http.StatusForbidden, http.StatusForbidden}, {http.StatusBadGateway, http.StatusInternalServerError}, } for _, tc := range cases { t.Run(http.StatusText(tc.upstream), func(t *testing.T) { client := newControllerReadStub() client.branchesErr = &GitHubAPIError{ StatusCode: tc.upstream, Endpoint: "/repos/acme/widget/branches", Body: "upstream said no", } router, _ := setupControllerTest(client) response := doControllerRequest( router, http.MethodGet, "/api/v1/github/repos/acme/widget/branches", ) if response.Code != tc.want { t.Fatalf("status = %d, want %d; body = %s", response.Code, tc.want, response.Body) } }) } } // GitHub not being configured is its own contract: a 503 carrying a stable // code the frontend switches on. func TestController_ListRepoBranches_NotConfiguredIs503WithCode(t *testing.T) { router, _ := setupControllerTest(nil) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/repos/acme/widget/branches") if response.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503; body = %s", response.Code, response.Body) } var body struct { Code string `json:"code"` Error string `json:"error"` } decodeControllerBody(t, response, &body) if body.Code != "github_not_configured" { t.Errorf("code = %q, want github_not_configured", body.Code) } if body.Error == "" { t.Error("error message is empty, want the remediation hint") } } func TestController_SearchUserPRs(t *testing.T) { client := newControllerReadStub() client.prPage = &PRSearchPage{ PRs: []*PR{{Number: 7, Title: "Fix auth", State: "open"}}, TotalCount: 1, } router, _ := setupControllerTest(client) response := doControllerRequest( router, http.MethodGet, "/api/v1/github/user/prs?filter=is:open&page=2&per_page=25", ) if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body) } var page PRSearchPage decodeControllerBody(t, response, &page) if len(page.PRs) != 1 || page.PRs[0].Number != 7 || page.PRs[0].Title != "Fix auth" { t.Fatalf("prs = %#v", page.PRs) } if page.Page != 2 || page.PerPage != 25 { t.Errorf("page/per_page = (%d, %d), want (2, 25)", page.Page, page.PerPage) } } // A nil PR slice must serialize as [] rather than null so the frontend can map // over the result without a guard. func TestController_SearchUserPRs_NilResultSerializesAsEmptyArray(t *testing.T) { client := newControllerReadStub() client.prPage = &PRSearchPage{PRs: nil, TotalCount: 0} router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/user/prs") if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body) } var raw map[string]json.RawMessage decodeControllerBody(t, response, &raw) if string(raw["prs"]) != "[]" { t.Errorf("prs = %s, want []", raw["prs"]) } } func TestController_SearchUserIssues(t *testing.T) { client := newControllerReadStub() client.issuePage = &IssueSearchPage{ Issues: []*Issue{{Number: 3, Title: "Broken login", State: "open"}}, TotalCount: 1, } router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/user/issues?query=is:issue") if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body) } var page IssueSearchPage decodeControllerBody(t, response, &page) if len(page.Issues) != 1 || page.Issues[0].Number != 3 || page.Issues[0].Title != "Broken login" { t.Fatalf("issues = %#v", page.Issues) } // Defaults apply when the caller sends no pagination params. if page.Page != 1 || page.PerPage != 50 { t.Errorf("page/per_page = (%d, %d), want the (1, 50) defaults", page.Page, page.PerPage) } } func TestController_SearchUserIssues_NilResultSerializesAsEmptyArray(t *testing.T) { client := newControllerReadStub() client.issuePage = &IssueSearchPage{Issues: nil} router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/user/issues") if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body) } var raw map[string]json.RawMessage decodeControllerBody(t, response, &raw) if string(raw["issues"]) != "[]" { t.Errorf("issues = %s, want []", raw["issues"]) } } // Both search endpoints route their failures through handleSearchError, which // forwards the recognised upstream statuses and collapses everything else. func TestController_SearchEndpoints_MapUpstreamStatuses(t *testing.T) { cases := []struct { upstream int want int }{ {http.StatusNotFound, http.StatusNotFound}, {http.StatusUnauthorized, http.StatusUnauthorized}, {http.StatusForbidden, http.StatusForbidden}, {http.StatusTeapot, http.StatusInternalServerError}, } for _, tc := range cases { t.Run(http.StatusText(tc.upstream), func(t *testing.T) { apiErr := &GitHubAPIError{StatusCode: tc.upstream, Endpoint: "/search/issues"} client := newControllerReadStub() client.prPageErr = apiErr client.issueErr = apiErr router, _ := setupControllerTest(client) for _, path := range []string{"/api/v1/github/user/prs", "/api/v1/github/user/issues"} { response := doControllerRequest(router, http.MethodGet, path) if response.Code != tc.want { t.Errorf("%s status = %d, want %d; body = %s", path, response.Code, tc.want, response.Body) } } }) } } func TestController_SearchEndpoints_NotConfiguredIs503WithCode(t *testing.T) { router, _ := setupControllerTest(nil) for _, path := range []string{"/api/v1/github/user/prs", "/api/v1/github/user/issues"} { response := doControllerRequest(router, http.MethodGet, path) if response.Code != http.StatusServiceUnavailable { t.Errorf("%s status = %d, want 503; body = %s", path, response.Code, response.Body) continue } var body struct { Code string `json:"code"` } decodeControllerBody(t, response, &body) if body.Code != "github_not_configured" { t.Errorf("%s code = %q, want github_not_configured", path, body.Code) } } } func TestController_GetPRFeedback(t *testing.T) { client := newControllerReadStub() client.feedback = &PRFeedback{ PR: &PR{Number: 42, Title: "Add widgets", State: "open"}, Reviews: []PRReview{{ID: 1, Author: "alice", State: "APPROVED"}}, Comments: []PRComment{}, Checks: []CheckRun{{Name: "unit", Status: "completed", Conclusion: "failure"}}, HasIssues: true, } router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/prs/acme/widget/42") if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body) } var body PRFeedback decodeControllerBody(t, response, &body) if body.PR == nil || body.PR.Number != 42 || body.PR.Title != "Add widgets" { t.Fatalf("PR = %#v", body.PR) } if len(body.Reviews) != 1 || body.Reviews[0].Author != "alice" { t.Errorf("reviews = %#v", body.Reviews) } if len(body.Checks) != 1 || body.Checks[0].Conclusion != "failure" { t.Errorf("checks = %#v", body.Checks) } if !body.HasIssues { t.Error("has_issues = false, want true") } } func TestController_GetPRFeedback_RejectsANonNumericPRNumber(t *testing.T) { router, _ := setupControllerTest(newControllerReadStub()) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/prs/acme/widget/abc") if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body) } var body struct { Error string `json:"error"` } decodeControllerBody(t, response, &body) if body.Error != "invalid PR number" { t.Errorf("error = %q", body.Error) } } func TestController_GetPRFeedback_UpstreamFailureIs500(t *testing.T) { client := newControllerReadStub() client.feedbackErr = &GitHubAPIError{StatusCode: http.StatusNotFound, Endpoint: "/repos/acme/widget/pulls/42"} router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/prs/acme/widget/42") if response.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500; body = %s", response.Code, response.Body) } } func TestController_GetPRStatus(t *testing.T) { client := newControllerReadStub() client.prStatus = &PRStatus{ PR: &PR{Number: 42, State: "open"}, ReviewState: computedReviewStateApproved, ChecksState: "failure", MergeableState: "blocked", ReviewCount: 2, ChecksTotal: 3, ChecksPassing: 2, ChecksPopulated: true, } router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/prs/acme/widget/42/status") if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body) } var body PRStatus decodeControllerBody(t, response, &body) if body.PR == nil || body.PR.Number != 42 { t.Fatalf("PR = %#v", body.PR) } if body.ReviewState != computedReviewStateApproved || body.ChecksState != "failure" { t.Errorf("states = (%q, %q)", body.ReviewState, body.ChecksState) } if body.MergeableState != "blocked" || body.ReviewCount != 2 { t.Errorf("mergeable/review count = (%q, %d)", body.MergeableState, body.ReviewCount) } if body.ChecksTotal != 3 || body.ChecksPassing != 2 || !body.ChecksPopulated { t.Errorf("checks = %d/%d populated=%v", body.ChecksPassing, body.ChecksTotal, body.ChecksPopulated) } } func TestController_GetPRStatus_RejectsANonNumericPRNumber(t *testing.T) { router, _ := setupControllerTest(newControllerReadStub()) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/prs/acme/widget/xyz/status") if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body) } } // httpGetPRStatus goes through handleSearchError, so unlike the feedback // endpoint it forwards the recognised upstream statuses. func TestController_GetPRStatus_PreservesUpstreamStatus(t *testing.T) { client := newControllerReadStub() client.statusErr = &GitHubAPIError{ StatusCode: http.StatusNotFound, Endpoint: "/repos/acme/widget/pulls/42", } router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/prs/acme/widget/42/status") if response.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404; body = %s", response.Code, response.Body) } } func TestController_GetStats_RejectsMalformedDates(t *testing.T) { cases := []struct { name string target string want string }{ { "bad start_date", "/api/v1/github/stats?start_date=01-02-2026", "invalid start_date format, expected YYYY-MM-DD", }, { "bad end_date", "/api/v1/github/stats?start_date=2026-01-01&end_date=nope", "invalid end_date format, expected YYYY-MM-DD", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { router, _ := setupControllerTest(newControllerReadStub()) response := doControllerRequest(router, http.MethodGet, tc.target) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body) } var body struct { Error string `json:"error"` } decodeControllerBody(t, response, &body) if body.Error != tc.want { t.Errorf("error = %q, want %q", body.Error, tc.want) } }) } } func TestController_GetWorkspaceSettings_RequiresAWorkspace(t *testing.T) { router, _ := setupControllerTest(newControllerReadStub()) request := httptest.NewRequest(http.MethodGet, "/api/v1/github/workspace-settings", nil) request.Header.Set("X-Test-Omit-Workspace", "1") response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body) } var body struct { Error string `json:"error"` } decodeControllerBody(t, response, &body) if body.Error != "workspace_id query parameter required" { t.Errorf("error = %q", body.Error) } } // Both auth endpoints route a missing workspace through writeGitHubAuthError, // so they owe the caller the 400 + github_workspace_required contract the // frontend switches on — not just "some failure". func TestController_ClearToken_RequiresAWorkspace(t *testing.T) { router, _ := setupControllerTest(newControllerReadStub()) request := httptest.NewRequest(http.MethodDelete, "/api/v1/github/token", nil) request.Header.Set("X-Test-Omit-Workspace", "1") response := httptest.NewRecorder() router.ServeHTTP(response, request) assertWorkspaceRequiredResponse(t, response) } func TestController_GetStatus_RequiresAWorkspace(t *testing.T) { router, _ := setupControllerTest(newControllerReadStub()) request := httptest.NewRequest(http.MethodGet, "/api/v1/github/status", nil) request.Header.Set("X-Test-Omit-Workspace", "1") response := httptest.NewRecorder() router.ServeHTTP(response, request) assertWorkspaceRequiredResponse(t, response) } func assertWorkspaceRequiredResponse(t *testing.T, response *httptest.ResponseRecorder) { t.Helper() if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body) } var body struct { Code string `json:"code"` Error string `json:"error"` } decodeControllerBody(t, response, &body) if body.Code != "github_workspace_required" { t.Errorf("code = %q, want github_workspace_required", body.Code) } if body.Error == "" { t.Error("error message is empty") } } // Guard the pagination defaults independently of any one endpoint: page floors // at 1 and per_page falls back to 50, so a malformed query can never ask GitHub // for page 0 or an empty page. func TestParsePaginationQuery(t *testing.T) { cases := []struct { name string query string wantPage int wantPerPage int }{ {"defaults", "", 1, 50}, {"explicit values", "?page=3&per_page=25", 3, 25}, {"zero page floors to 1", "?page=0&per_page=25", 1, 25}, {"negative page floors to 1", "?page=-4&per_page=25", 1, 25}, {"zero per_page falls back", "?page=2&per_page=0", 2, 50}, {"negative per_page falls back", "?page=2&per_page=-1", 2, 50}, {"non-numeric values fall back", "?page=abc&per_page=xyz", 1, 50}, // The 100 cap lives in clampSearchPage, not here. {"large per_page passes through", "?page=1&per_page=5000", 1, 5000}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctx := newPaginationGinContext(t, tc.query) page, perPage := parsePaginationQuery(ctx) if page != tc.wantPage || perPage != tc.wantPerPage { t.Errorf("parsePaginationQuery(%q) = (%d, %d), want (%d, %d)", tc.query, page, perPage, tc.wantPage, tc.wantPerPage) } }) } } func TestController_ListUserOrgs_ContextCancellationPropagates(t *testing.T) { client := newControllerReadStub() client.orgsErr = context.Canceled router, _ := setupControllerTest(client) response := doControllerRequest(router, http.MethodGet, "/api/v1/github/orgs") if response.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500; body = %s", response.Code, response.Body) } } func TestController_SearchRepos_HonoursTheRequestDeadline(t *testing.T) { client := newControllerReadStub() client.orgReposErr = context.DeadlineExceeded router, _ := setupControllerTest(client) request := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos/search?org=acme", nil) ctx, cancel := context.WithTimeout(request.Context(), time.Millisecond) defer cancel() request = request.WithContext(ctx) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500; body = %s", response.Code, response.Body) } } // newPaginationGinContext builds a bare gin context carrying only the query // string, so parsePaginationQuery can be exercised without a route. func newPaginationGinContext(t *testing.T, query string) *gin.Context { t.Helper() ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = httptest.NewRequest(http.MethodGet, "/search"+query, nil) return ctx }