package api import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" ) // issuesServer stubs the issue/milestone/device surface, recording every // request (method+path+body) and replying from a scripted map. type recordedRequest struct { Method string Path string Body string } func newIssuesServer(t *testing.T) (*httptest.Server, *[]recordedRequest, map[string]string) { t.Helper() var calls []recordedRequest responses := map[string]string{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body string if r.Body != nil { buf := make([]byte, 4096) n, _ := r.Body.Read(buf) body = string(buf[:n]) } calls = append(calls, recordedRequest{Method: r.Method, Path: r.URL.Path, Body: body}) resp, ok := responses[r.Method+" "+r.URL.Path] if !ok { w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "not_found", "message": "no"}}) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(resp)) })) t.Cleanup(srv.Close) return srv, &calls, responses } func TestIssueEndpointsRoundTrip(t *testing.T) { srv, calls, responses := newIssuesServer(t) responses["GET /api/v1/repos/o/r/issues"] = `{"items":[{"number":7,"title":"bug","state":"open","author":"rick","labels":[{"id":"1","name":"bug","color":"d73a4a"}],"milestone":{"id":"m1","title":"v1.0","state":"open","open_issues":2,"closed_issues":1}}],"page":1,"per_page":30,"has_next":false}` responses["POST /api/v1/repos/o/r/issues"] = `{"number":8,"title":"new","state":"open","author":"rick","body":"the body","labels":[],"comments":[]}` responses["GET /api/v1/repos/o/r/issues/7"] = `{"number":7,"title":"bug","state":"open","author":"rick","body":"spicy","labels":[],"comments":[{"author":"rick","body":"first","createdAt":"2026-01-01T00:00:00Z"}],"assignees":[{"handle":"rick"}]}` responses["POST /api/v1/repos/o/r/issues/7/state"] = `{"number":7,"state":"closed","title":"bug"}` responses["POST /api/v1/repos/o/r/issues/7/comments"] = `{"author":"rick","body":"hi"}` responses["POST /api/v1/repos/o/r/issues/7/labels"] = `` responses["POST /api/v1/repos/o/r/issues/7/milestone"] = `` responses["POST /api/v1/repos/o/r/issues/7/assignees"] = `` responses["DELETE /api/v1/repos/o/r/milestones/m1"] = `` responses["GET /api/v1/repos/o/r/labels"] = `[{"id":"1","name":"bug","color":"d73a4a"}]` responses["GET /api/v1/repos/o/r/milestones"] = `[{"id":"m1","title":"v1.0","state":"open","open_issues":2,"closed_issues":1}]` responses["POST /api/v1/repos/o/r/milestones"] = `{"id":"m1","title":"v1.0","state":"open"}` responses["PATCH /api/v1/repos/o/r/milestones/m1"] = `{"id":"m1","title":"v1.0","state":"closed"}` c := New(srv.URL, "rickub_pat_x") ctx := context.Background() page, err := c.ListIssues(ctx, "o", "r", "open", 0, 0) if err != nil || len(page.Items) != 1 || page.Items[0].Milestone.Title != "v1.0" || page.Items[0].Labels[0].Name != "bug" { t.Fatalf("ListIssues: %+v err=%v", page, err) } created, err := c.CreateIssue(ctx, "o", "r", "new", "the body") if err != nil || created.Number != 8 || created.Body != "the body" { t.Fatalf("CreateIssue: %+v err=%v", created, err) } detail, err := c.GetIssue(ctx, "o", "r", 7) if err != nil || len(detail.Comments) != 1 || len(detail.Assignees) != 1 || detail.Assignees[0].Handle != "rick" { t.Fatalf("GetIssue: %+v err=%v", detail, err) } closed, err := c.SetIssueState(ctx, "o", "r", 7, "closed") if err != nil || closed.State != "closed" { t.Fatalf("SetIssueState: %+v err=%v", closed, err) } if _, err := c.CommentIssue(ctx, "o", "r", 7, "hi"); err != nil { t.Fatalf("CommentIssue: %v", err) } if err := c.SetIssueLabels(ctx, "o", "r", 7, []string{"bug"}); err != nil { t.Fatalf("SetIssueLabels: %v", err) } if err := c.SetIssueMilestone(ctx, "o", "r", 7, "v1.0"); err != nil { t.Fatalf("SetIssueMilestone: %v", err) } if err := c.SetIssueAssignee(ctx, "o", "r", 7, "add", "rick"); err != nil { t.Fatalf("SetIssueAssignee: %v", err) } labels, err := c.ListLabels(ctx, "o", "r") if err != nil || len(labels) != 1 || labels[0].Color != "d73a4a" { t.Fatalf("ListLabels: %+v err=%v", labels, err) } mses, err := c.ListMilestones(ctx, "o", "r", "open") if err != nil || len(mses) != 1 || mses[0].OpenCount != 2 || mses[0].ClosedCount != 1 { t.Fatalf("ListMilestones: %+v err=%v", mses, err) } if _, err := c.CreateMilestone(ctx, "o", "r", "v1.0", "", "2026-12-31"); err != nil { t.Fatalf("CreateMilestone: %v", err) } updated, err := c.UpdateMilestone(ctx, "o", "r", "m1", map[string]any{"state": "closed"}) if err != nil || updated.State != "closed" { t.Fatalf("UpdateMilestone: %+v err=%v", updated, err) } // The mutation bodies carry what the server expects. want := map[string]recordedRequest{ "POST /api/v1/repos/o/r/issues": {Method: "POST", Path: "/api/v1/repos/o/r/issues", Body: `{"body":"the body","title":"new"}`}, "POST /api/v1/repos/o/r/issues/7/state": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/state", Body: `{"state":"closed"}`}, "POST /api/v1/repos/o/r/issues/7/labels": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/labels", Body: `{"labels":["bug"]}`}, "POST /api/v1/repos/o/r/issues/7/milestone": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/milestone", Body: `{"milestone":"v1.0"}`}, "POST /api/v1/repos/o/r/issues/7/assignees": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/assignees", Body: `{"op":"add","user":"rick"}`}, "PATCH /api/v1/repos/o/r/milestones/m1": {Method: "PATCH", Path: "/api/v1/repos/o/r/milestones/m1", Body: `{"state":"closed"}`}, } byKey := map[string]recordedRequest{} for _, c := range *calls { byKey[c.Method+" "+c.Path] = c } for key, w := range want { if got := byKey[key]; got.Body != w.Body { t.Errorf("%s body = %s, want %s", key, got.Body, w.Body) } } } func TestDeviceFlowClientRoundTrip(t *testing.T) { var sawAuth bool var tokenPathCalls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if h := r.Header.Get("Authorization"); h != "" { sawAuth = true } switch { case r.Method == "POST" && r.URL.Path == "/api/v1/device/code": _, _ = w.Write([]byte(`{"device_code":"dc","user_code":"ABCD-EFGH","verification_url":"https://x/login/device","verification_uri_complete":"https://x/login/device?user_code=ABCD-EFGH","expires_in":600,"interval":1}`)) case r.Method == "POST" && r.URL.Path == "/api/v1/device/token": tokenPathCalls++ if tokenPathCalls == 1 { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":{"code":"authorization_pending","message":"keep polling"}}`)) return } _, _ = w.Write([]byte(`{"access_token":"rickub_pat_minted","token_type":"bearer","scope":"all"}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() c := New(srv.URL, "") // tokenless: the device code is the credential start, err := c.StartDeviceLogin(context.Background(), "all", "test client") if err != nil || start.UserCode != "ABCD-EFGH" || start.Interval != 1 { t.Fatalf("StartDeviceLogin: %+v err=%v", start, err) } // First poll: pending → *APIError with the machine code. if _, err := c.PollDeviceToken(context.Background(), start.DeviceCode); err == nil { t.Fatal("first poll should error") } else { var apiErr *APIError if !errors.As(err, &apiErr) || apiErr.Code != "authorization_pending" { t.Fatalf("pending error: %v", err) } } // Second poll: minted token. tok, err := c.PollDeviceToken(context.Background(), start.DeviceCode) if err != nil || tok.AccessToken != "rickub_pat_minted" { t.Fatalf("PollDeviceToken: %+v err=%v", tok, err) } if sawAuth { t.Error("device endpoints must not send an Authorization header on a tokenless client") } }