Files
multica/server/internal/handler/project_pull_requests_test.go
Claude 9df876cae2 feat(projects): project pull requests endpoint
Before: a project had no way to list the pull requests its issues delivered; the only PR list was per issue, so a project-level view would have to fan out one request per issue and could not page or count them.

After: GET /api/projects/{id}/pull-requests returns the distinct GitHub and self-hosted VCS PRs linked to the project's issues, newest update first, with the issue PR list's fields (checks rollup, mergeable, additions, deletions) plus the project issues each PR is linked to. state=open|merged|closed filters (open covers draft, closed is closed without merging), limit/offset page per PR, and the response carries total and per-group counts.

How: ListProjectPullRequestPage unions both PR tables and de-duplicates links so paging is per PR; ListProjectPullRequests / ListProjectVCSPullRequests then load full rows for the page ids with the same columns as the per-issue queries, so the existing row serializers are reused unchanged (including the page-visit snapshot refresh). Issues are attached from one extra query. CountProjectPullRequestsByState feeds total and counts. No migration: additions/deletions already exist. DB-backed handler tests are written but not run here (no PostgreSQL).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
2026-10-21 07:44:55 +00:00

193 lines
7.2 KiB
Go

package handler
import (
"net/http"
"testing"
"github.com/multica-ai/multica/server/internal/testutil"
)
type projectPullRequestsBody struct {
PullRequests []ProjectPullRequestResponse `json:"pull_requests"`
Total int64 `json:"total"`
Counts ProjectPullRequestCountsResponse `json:"counts"`
}
func listProjectPullRequests(t *testing.T, projectID, query string) projectPullRequestsBody {
t.Helper()
var out projectPullRequestsBody
req := newRequest(http.MethodGet, "/api/projects/"+projectID+"/pull-requests"+query, nil)
req = withURLParam(req, "id", projectID)
testutil.Call(t, testHandler.ListProjectPullRequests, req).Want(http.StatusOK).JSON(&out)
return out
}
// linkGitHubPR links an existing GitHub PR to one more issue.
func linkGitHubPR(t *testing.T, issueID, prID string) {
t.Helper()
dbfx.InsertNoID(t, "issue_pull_request",
testutil.Cols{"issue_id": issueID, "pull_request_id": prID},
"issue_id = $1 AND pull_request_id = $2", issueID, prID)
}
func TestListProjectPullRequests_MergesSharedPRIntoOneRow(t *testing.T) {
at := outputsClock()
projectID := dbfx.Project(t, "pr tab shared")
first := dbfx.Issue(t, "first", testutil.Cols{"project_id": projectID})
second := dbfx.Issue(t, "second", testutil.Cols{"project_id": projectID})
shared := seedGitHubPR(t, first, 1, at(1))
linkGitHubPR(t, second, shared)
solo := seedGitHubPR(t, second, 2, at(2))
out := listProjectPullRequests(t, projectID, "")
if out.Total != 2 || len(out.PullRequests) != 2 {
t.Fatalf("total=%d rows=%d, want 2 distinct PRs", out.Total, len(out.PullRequests))
}
byID := map[string]ProjectPullRequestResponse{}
for _, pr := range out.PullRequests {
byID[pr.ID] = pr
}
if got := len(byID[shared].Issues); got != 2 {
t.Fatalf("shared PR issues = %d, want 2", got)
}
if got := byID[solo].Issues; len(got) != 1 || got[0].ID != second {
t.Fatalf("solo PR issues = %+v, want only the second issue", got)
}
for _, issue := range byID[shared].Issues {
if issue.Identifier == "" || issue.Title == "" {
t.Fatalf("issue chip missing identifier or title: %+v", issue)
}
}
if byID[solo].Additions != 12 || byID[solo].Deletions != 2 {
t.Fatalf("solo PR diff = +%d -%d, want +12 -2", byID[solo].Additions, byID[solo].Deletions)
}
}
func TestListProjectPullRequests_ExcludesOtherProjectsAndOrdersByUpdate(t *testing.T) {
at := outputsClock()
projectID := dbfx.Project(t, "pr tab scope")
otherProjectID := dbfx.Project(t, "pr tab other")
inProject := dbfx.Issue(t, "in project", testutil.Cols{"project_id": projectID})
inOther := dbfx.Issue(t, "in other project", testutil.Cols{"project_id": otherProjectID})
noProject := dbfx.Issue(t, "no project")
older := seedGitHubPR(t, inProject, 1, at(1))
newer := seedGitHubPR(t, inProject, 2, at(5))
seedGitHubPR(t, inOther, 3, at(9))
seedGitHubPR(t, noProject, 4, at(10))
vcs := seedVCSPR(t, inProject, 5, at(3), "sha-scope", "passed", "failed")
out := listProjectPullRequests(t, projectID, "")
if out.Total != 3 {
t.Fatalf("total = %d, want 3", out.Total)
}
var ids []string
for _, pr := range out.PullRequests {
ids = append(ids, pr.ID)
}
want := []string{newer, vcs, older}
for i := range want {
if i >= len(ids) || ids[i] != want[i] {
t.Fatalf("order = %v, want %v (newest update first, GitHub and VCS merged)", ids, want)
}
}
if out.PullRequests[1].Provider != "gitea" {
t.Fatalf("provider = %q, want gitea", out.PullRequests[1].Provider)
}
if got := out.PullRequests[1].ChecksConclusion; got == nil || *got != "failed" {
t.Fatalf("vcs checks_conclusion = %v, want failed", got)
}
}
func TestListProjectPullRequests_StateFilterAndCounts(t *testing.T) {
at := outputsClock()
projectID := dbfx.Project(t, "pr tab states")
issue := dbfx.Issue(t, "states", testutil.Cols{"project_id": projectID})
open := seedGitHubPR(t, issue, 1, at(1))
draft := seedGitHubPR(t, issue, 2, at(2), testutil.Cols{"state": "draft"})
merged := seedGitHubPR(t, issue, 3, at(3), testutil.Cols{"state": "merged", "merged_at": at(3)})
closed := seedGitHubPR(t, issue, 4, at(4), testutil.Cols{"state": "closed", "closed_at": at(4)})
cases := []struct {
state string
want []string
}{
{"open", []string{draft, open}},
{"merged", []string{merged}},
{"closed", []string{closed}},
{"", []string{closed, merged, draft, open}},
}
for _, tc := range cases {
query := ""
if tc.state != "" {
query = "?state=" + tc.state
}
out := listProjectPullRequests(t, projectID, query)
if out.Total != int64(len(tc.want)) {
t.Fatalf("state %q: total = %d, want %d", tc.state, out.Total, len(tc.want))
}
for i, id := range tc.want {
if i >= len(out.PullRequests) || out.PullRequests[i].ID != id {
t.Fatalf("state %q: rows out of order or missing", tc.state)
}
}
// Counts cover every group whatever the filter.
if out.Counts.Open != 2 || out.Counts.Merged != 1 || out.Counts.Closed != 1 {
t.Fatalf("state %q: counts = %+v, want open 2 merged 1 closed 1", tc.state, out.Counts)
}
}
req := newRequest(http.MethodGet, "/api/projects/"+projectID+"/pull-requests?state=bogus", nil)
req = withURLParam(req, "id", projectID)
testutil.Call(t, testHandler.ListProjectPullRequests, req).Want(http.StatusBadRequest)
}
func TestListProjectPullRequests_PagesPerPullRequest(t *testing.T) {
at := outputsClock()
projectID := dbfx.Project(t, "pr tab paging")
first := dbfx.Issue(t, "first", testutil.Cols{"project_id": projectID})
second := dbfx.Issue(t, "second", testutil.Cols{"project_id": projectID})
// Three PRs, the newest linked to both issues: paging counts it once.
oldest := seedGitHubPR(t, first, 1, at(1))
middle := seedGitHubPR(t, first, 2, at(2))
newest := seedGitHubPR(t, first, 3, at(3))
linkGitHubPR(t, second, newest)
page1 := listProjectPullRequests(t, projectID, "?limit=2")
if page1.Total != 3 || len(page1.PullRequests) != 2 {
t.Fatalf("page 1: total=%d rows=%d, want 3 and 2", page1.Total, len(page1.PullRequests))
}
if page1.PullRequests[0].ID != newest || page1.PullRequests[1].ID != middle {
t.Fatalf("page 1 order wrong")
}
if len(page1.PullRequests[0].Issues) != 2 {
t.Fatalf("shared PR on page 1 has %d issues, want 2", len(page1.PullRequests[0].Issues))
}
page2 := listProjectPullRequests(t, projectID, "?limit=2&offset=2")
if page2.Total != 3 || len(page2.PullRequests) != 1 || page2.PullRequests[0].ID != oldest {
t.Fatalf("page 2: total=%d rows=%d, want the oldest PR alone", page2.Total, len(page2.PullRequests))
}
beyond := listProjectPullRequests(t, projectID, "?limit=2&offset=10")
if beyond.Total != 3 || len(beyond.PullRequests) != 0 {
t.Fatalf("past the end: total=%d rows=%d, want 3 and 0", beyond.Total, len(beyond.PullRequests))
}
}
func TestListProjectPullRequests_EmptyAndMissingProject(t *testing.T) {
projectID := dbfx.Project(t, "pr tab empty")
out := listProjectPullRequests(t, projectID, "")
if out.Total != 0 || out.PullRequests == nil || len(out.PullRequests) != 0 {
t.Fatalf("empty project: %+v", out)
}
missing := "00000000-0000-4000-8000-000000000000"
req := newRequest(http.MethodGet, "/api/projects/"+missing+"/pull-requests", nil)
req = withURLParam(req, "id", missing)
testutil.Call(t, testHandler.ListProjectPullRequests, req).Want(http.StatusNotFound)
}