Files
kandev/apps/backend/internal/github/pat_client_contents_test.go
T

400 lines
15 KiB
Go

package github
import (
"context"
"encoding/base64"
"errors"
"net/http"
"strings"
"testing"
"time"
)
func TestPATClient_ListRepoDirectory(t *testing.T) {
c, requests := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/contents/docs/specs": `[
{"name":"spec.md","path":"docs/specs/spec.md","type":"file","sha":"aaa","size":128},
{"name":"images","path":"docs/specs/images","type":"dir","sha":"bbb","size":0}
]`,
})
entries, err := c.ListRepoDirectory(context.Background(), "acme", "widget", "docs/specs", "main")
if err != nil {
t.Fatalf("ListRepoDirectory: %v", err)
}
want := []RepoContentEntry{
{Name: "spec.md", Path: "docs/specs/spec.md", Type: RepoContentTypeFile, SHA: "aaa", Size: 128},
{Name: "images", Path: "docs/specs/images", Type: RepoContentTypeDir, SHA: "bbb"},
}
if len(entries) != len(want) {
t.Fatalf("entries = %#v, want %#v", entries, want)
}
for i := range want {
if entries[i] != want[i] {
t.Errorf("entries[%d] = %#v, want %#v", i, entries[i], want[i])
}
}
if got := parseQueryValues(t, (*requests)[0].Query).Get("ref"); got != "main" {
t.Errorf("ref = %q, want main", got)
}
}
func TestPATClient_ListRepoDirectory_RootOmitsThePathSegment(t *testing.T) {
c, requests := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/contents": `[]`,
})
if _, err := c.ListRepoDirectory(context.Background(), "acme", "widget", "", ""); err != nil {
t.Fatalf("ListRepoDirectory: %v", err)
}
if (*requests)[0].Path != "/repos/acme/widget/contents" {
t.Errorf("path = %q, want the bare contents endpoint", (*requests)[0].Path)
}
if (*requests)[0].Query != "" {
t.Errorf("query = %q, want no ref parameter", (*requests)[0].Query)
}
}
func TestPATClient_ListRepoDirectory_MissingDirectoryIsTyped(t *testing.T) {
c, _ := newRecordingPATServer(t, nil)
entries, err := c.ListRepoDirectory(context.Background(), "acme", "widget", "nope", "main")
if entries != nil {
t.Errorf("entries = %#v, want nil", entries)
}
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
t.Fatalf("err = %v, want an unwrappable *GitHubAPIError with 404", err)
}
if !strings.Contains(err.Error(), "list repo directory") {
t.Errorf("err = %v, want the wrap to name the operation", err)
}
}
func TestPATClient_GetRepoFileContent(t *testing.T) {
content := "package main\n\nfunc main() {}\n"
// GitHub wraps base64 content at 60 characters; the decoder must strip
// those newlines before decoding. `\n` here is the two-character JSON
// escape, so the decoded string really does carry an embedded newline.
encoded := base64.StdEncoding.EncodeToString([]byte(content))
wrapped := encoded[:8] + `\n` + encoded[8:]
c, requests := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/contents/cmd/main.go": `{"type":"file","encoding":"base64","content":"` + wrapped + `"}`,
})
got, err := c.GetRepoFileContent(context.Background(), "acme", "widget", "cmd/main.go", "abc123")
if err != nil {
t.Fatalf("GetRepoFileContent: %v", err)
}
if string(got) != content {
t.Errorf("content = %q, want %q", got, content)
}
if got := parseQueryValues(t, (*requests)[0].Query).Get("ref"); got != "abc123" {
t.Errorf("ref = %q, want abc123", got)
}
}
func TestPATClient_GetRepoFileContent_RejectsUnsupportedEncoding(t *testing.T) {
c, _ := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/contents/big.bin": `{"type":"file","encoding":"none","content":""}`,
})
got, err := c.GetRepoFileContent(context.Background(), "acme", "widget", "big.bin", "")
if got != nil {
t.Errorf("content = %q, want nil", got)
}
if err == nil || !strings.Contains(err.Error(), `unsupported encoding "none"`) {
t.Fatalf("err = %v, want it to name the unsupported encoding", err)
}
}
func TestPATClient_GetRepoFileContent_RejectsUndecodableBase64(t *testing.T) {
c, _ := newRecordingPATServer(t, map[string]string{
"/repos/acme/widget/contents/x": `{"type":"file","encoding":"base64","content":"!!!not base64!!!"}`,
})
if _, err := c.GetRepoFileContent(context.Background(), "acme", "widget", "x", ""); err == nil ||
!strings.Contains(err.Error(), "decode base64") {
t.Fatalf("err = %v, want a base64 decode failure", err)
}
}
func TestPATClient_GetRepoFileContent_MissingFileIsTyped(t *testing.T) {
c, _ := newRecordingPATServer(t, nil)
_, err := c.GetRepoFileContent(context.Background(), "acme", "widget", "gone.txt", "")
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotFound {
t.Fatalf("err = %v, want an unwrappable *GitHubAPIError with 404", err)
}
}
func TestPATClient_GetPRStatus_RollsUpReviewsAndChecks(t *testing.T) {
routes := prStatusRoutes()
routes["/repos/acme/widget/pulls/42/reviews"] = `[
{"id":1,"state":"APPROVED","submitted_at":"2026-01-05T10:00:00Z","user":{"login":"alice"}},
{"id":2,"state":"APPROVED","submitted_at":"2026-01-06T10:00:00Z","user":{"login":"bob"}}
]`
c, _ := newRecordingPATServer(t, routes)
status, err := c.GetPRStatus(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("GetPRStatus: %v", err)
}
if status.PR == nil || status.PR.Number != 42 {
t.Fatalf("PR = %#v", status.PR)
}
if status.ReviewState != computedReviewStateApproved {
t.Errorf("review state = %q, want approved", status.ReviewState)
}
if status.ReviewCount != 2 {
t.Errorf("review count = %d, want 2 distinct approving reviewers", status.ReviewCount)
}
if status.PendingReviewCount != 0 {
t.Errorf("pending review count = %d, want 0", status.PendingReviewCount)
}
if status.ChecksState != "failure" {
t.Errorf("checks state = %q, want failure", status.ChecksState)
}
if status.ChecksTotal != 2 || status.ChecksPassing != 1 {
t.Errorf("checks = %d/%d, want 1/2", status.ChecksPassing, status.ChecksTotal)
}
if status.MergeableState != "blocked" {
t.Errorf("mergeable state = %q, want blocked", status.MergeableState)
}
if !status.ChecksPopulated || !status.ReviewCountsPopulated {
t.Error("the REST path counted checks and reviews; both Populated flags must be set")
}
// The REST path never fetches review threads, so it must not claim to
// have counted them — SyncTaskPR relies on that to preserve the GraphQL value.
if status.UnresolvedReviewThreadsPopulated {
t.Error("UnresolvedReviewThreadsPopulated must stay false on the REST path")
}
}
// ReviewState and ReviewCount deliberately answer different questions, and an
// approver who later leaves a plain comment is where they diverge:
// ReviewState takes each author's strictly-latest review (COMMENTED, so the
// PR is no longer "all approved"), while ReviewCount ignores non-binding
// states so the popover still renders "Approved (1)".
func TestPATClient_GetPRStatus_LaterCommentFromAnApprover(t *testing.T) {
routes := prStatusRoutes()
routes["/repos/acme/widget/pulls/42/reviews"] = `[
{"id":1,"state":"APPROVED","submitted_at":"2026-01-05T10:00:00Z","user":{"login":"alice"}},
{"id":2,"state":"COMMENTED","submitted_at":"2026-01-06T10:00:00Z","user":{"login":"alice"}}
]`
c, _ := newRecordingPATServer(t, routes)
status, err := c.GetPRStatus(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("GetPRStatus: %v", err)
}
if status.ReviewState != computedReviewStatePending {
t.Errorf("review state = %q, want pending", status.ReviewState)
}
if status.ReviewCount != 1 {
t.Errorf("review count = %d, want 1 — a later comment must not drop the approval", status.ReviewCount)
}
if status.PendingReviewCount != 1 {
t.Errorf("pending review count = %d, want 1", status.PendingReviewCount)
}
}
// A requested-but-not-yet-submitted reviewer is the pending signal that wins
// over any derived-from-reviews count.
func TestPATClient_GetPRStatus_RequestedReviewersDrivePendingCount(t *testing.T) {
routes := prStatusRoutes()
routes["/repos/acme/widget/pulls/42"] = `{"number":42,"state":"open","mergeable_state":"BLOCKED",
"requested_reviewers":[{"login":"carol"},{"login":"dave"}],
"user":{"login":"bob"},"head":{"ref":"feature","sha":"headsha"},"base":{"ref":"main"}}`
routes["/repos/acme/widget/pulls/42/reviews"] = `[]`
c, _ := newRecordingPATServer(t, routes)
status, err := c.GetPRStatus(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("GetPRStatus: %v", err)
}
if status.PendingReviewCount != 2 {
t.Errorf("pending review count = %d, want 2 requested reviewers", status.PendingReviewCount)
}
if status.ReviewState != computedReviewStatePending {
t.Errorf("review state = %q, want pending", status.ReviewState)
}
if status.ReviewCount != 0 {
t.Errorf("review count = %d, want 0 — nobody has approved yet", status.ReviewCount)
}
}
func TestPATClient_GetPRFeedback_AggregatesReviewsCommentsAndChecks(t *testing.T) {
routes := prStatusRoutes()
routes["/repos/acme/widget/pulls/42/comments"] = `[{"id":20,"body":"inline",
"created_at":"2026-01-05T12:00:00Z","user":{"login":"alice","type":"User"}}]`
routes["/repos/acme/widget/issues/42/comments"] = `[]`
c, _ := newRecordingPATServer(t, routes)
feedback, err := c.GetPRFeedback(context.Background(), "acme", "widget", 42)
if err != nil {
t.Fatalf("GetPRFeedback: %v", err)
}
if feedback.PR == nil || feedback.PR.Number != 42 {
t.Fatalf("PR = %#v", feedback.PR)
}
if len(feedback.Reviews) != 2 || len(feedback.Checks) != 2 {
t.Errorf("reviews/checks = (%d, %d), want (2, 2)", len(feedback.Reviews), len(feedback.Checks))
}
if len(feedback.Comments) != 1 || feedback.Comments[0].ID != 20 {
t.Errorf("comments = %#v", feedback.Comments)
}
// A failing check alone is enough to flag the PR as needing attention.
if !feedback.HasIssues {
t.Error("HasIssues = false, want true — one check concluded failure")
}
}
func TestPATClient_GetPRFeedback_PropagatesAFailedLeg(t *testing.T) {
routes := prStatusRoutes()
// Drop the check-runs route so that leg 404s while the others succeed.
delete(routes, "/repos/acme/widget/commits/headsha/check-runs")
routes["/repos/acme/widget/pulls/42/comments"] = `[]`
routes["/repos/acme/widget/issues/42/comments"] = `[]`
c, _ := newRecordingPATServer(t, routes)
feedback, err := c.GetPRFeedback(context.Background(), "acme", "widget", 42)
if err == nil {
t.Fatal("expected the failing check-runs leg to abort the aggregate")
}
if feedback != nil {
t.Errorf("feedback = %#v, want nil", feedback)
}
}
// prStatusRoutes is the four-endpoint fixture the PR status/feedback rollups
// read: the PR, its reviews, its check runs, and its commit status contexts.
func prStatusRoutes() map[string]string {
return map[string]string{
"/repos/acme/widget/pulls/42": `{"number":42,"state":"open","mergeable_state":"BLOCKED",
"user":{"login":"bob"},"head":{"ref":"feature","sha":"headsha"},"base":{"ref":"main"}}`,
"/repos/acme/widget/pulls/42/reviews": `[
{"id":1,"state":"APPROVED","submitted_at":"2026-01-05T10:00:00Z","user":{"login":"alice"}},
{"id":2,"state":"COMMENTED","submitted_at":"2026-01-06T10:00:00Z","user":{"login":"alice"}}
]`,
"/repos/acme/widget/commits/headsha/check-runs": `{"check_runs":[
{"name":"unit","status":"completed","conclusion":"success","html_url":"https://ci/unit"},
{"name":"lint","status":"completed","conclusion":"failure","html_url":"https://ci/lint"}
]}`,
"/repos/acme/widget/commits/headsha/status": `{"statuses":[]}`,
}
}
func TestPATClient_ContextCancellationSurfaces(t *testing.T) {
c, requests := newRecordingPATServer(t, map[string]string{"/user": `{"login":"octocat"}`})
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := c.GetAuthenticatedUser(ctx)
if err == nil {
t.Fatal("expected an error from a cancelled context")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want it to unwrap to context.Canceled", err)
}
if len(*requests) != 0 {
t.Errorf("requests = %d, want 0 — a cancelled context must not reach the server", len(*requests))
}
}
func TestParseNextLink(t *testing.T) {
cases := []struct {
name string
header string
want string
}{
{"empty header", "", ""},
{
"next link is stripped down to path and query",
`<` + githubAPIBase + `/repos/o/r/branches?per_page=100&page=2>; rel="next"`,
"/repos/o/r/branches?per_page=100&page=2",
},
{
"next is picked out of a multi-rel header",
`<` + githubAPIBase + `/x?page=2>; rel="next", <` + githubAPIBase + `/x?page=9>; rel="last"`,
"/x?page=2",
},
{
"a last-only header has no next page",
`<` + githubAPIBase + `/x?page=9>; rel="last"`,
"",
},
{
"an absolute URL off another host is returned verbatim",
`<https://ghe.example.com/api/v3/x?page=2>; rel="next"`,
"https://ghe.example.com/api/v3/x?page=2",
},
{"malformed brackets are skipped", `https://api.github.com/x?page=2; rel="next"`, ""},
{"reversed brackets are skipped", `>` + githubAPIBase + `/x<; rel="next"`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseNextLink(tc.header); got != tc.want {
t.Errorf("parseNextLink(%q) = %q, want %q", tc.header, got, tc.want)
}
})
}
}
func TestFilterReposByQuery(t *testing.T) {
repos := []GitHubRepo{
{FullName: "acme/Widget"},
{FullName: "acme/gadget"},
{FullName: "other/widget-tools"},
}
t.Run("empty query returns the input unchanged", func(t *testing.T) {
got := filterReposByQuery(repos, "")
if len(got) != 3 {
t.Fatalf("repos = %#v, want all three", got)
}
})
t.Run("match is case-insensitive over full_name", func(t *testing.T) {
got := filterReposByQuery(repos, "WIDGET")
if len(got) != 2 {
t.Fatalf("repos = %#v, want acme/Widget and other/widget-tools", got)
}
if got[0].FullName != "acme/Widget" || got[1].FullName != "other/widget-tools" {
t.Errorf("repos = %#v", got)
}
})
t.Run("no match yields an empty, non-nil slice", func(t *testing.T) {
got := filterReposByQuery(repos, "nothing")
if got == nil || len(got) != 0 {
t.Fatalf("repos = %#v, want an empty slice", got)
}
})
}
func TestConvertRepoListItems(t *testing.T) {
pushed := time.Date(2026, 5, 4, 3, 2, 1, 0, time.UTC)
items := []repoListItem{
{
FullName: "acme/widget", Name: "widget", Private: true,
DefaultBranch: "trunk", Description: "a widget", PushedAt: pushed,
},
{FullName: "acme/quiet", Name: "quiet"},
}
items[0].Owner.Login = "acme"
items[1].Owner.Login = "acme"
got := convertRepoListItems(items)
if len(got) != 2 {
t.Fatalf("repos = %d, want 2", len(got))
}
if got[0].FullName != "acme/widget" || got[0].Owner != "acme" || got[0].Name != "widget" {
t.Errorf("repos[0] identity = %#v", got[0])
}
if !got[0].Private || got[0].DefaultBranch != "trunk" || got[0].Description != "a widget" {
t.Errorf("repos[0] attributes = %#v", got[0])
}
if got[0].PushedAt == nil || !got[0].PushedAt.Equal(pushed) {
t.Errorf("repos[0] pushed_at = %v, want %v", got[0].PushedAt, pushed)
}
// A zero pushed_at must stay nil so `omitempty` drops it instead of
// serializing "0001-01-01T00:00:00Z".
if got[1].PushedAt != nil {
t.Errorf("repos[1] pushed_at = %v, want nil", got[1].PushedAt)
}
}