rickub/clipublic Fork 0
1a1d4302402433417a40905bf2e2a7b85504872e
Commits
Clone
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git
endpoints_issues_test.go · 182 lines · 7.8 KBGo Blame HistoryRaw
Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 13h ago1package api
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10)
11
12// issuesServer stubs the issue/milestone/device surface, recording every
13// request (method+path+body) and replying from a scripted map.
14type recordedRequest struct {
15 Method string
16 Path string
17 Body string
18}
19
20func newIssuesServer(t *testing.T) (*httptest.Server, *[]recordedRequest, map[string]string) {
21 t.Helper()
22 var calls []recordedRequest
23 responses := map[string]string{}
24 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 var body string
26 if r.Body != nil {
27 buf := make([]byte, 4096)
28 n, _ := r.Body.Read(buf)
29 body = string(buf[:n])
30 }
31 calls = append(calls, recordedRequest{Method: r.Method, Path: r.URL.Path, Body: body})
32 resp, ok := responses[r.Method+" "+r.URL.Path]
33 if !ok {
34 w.WriteHeader(http.StatusNotFound)
35 _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "not_found", "message": "no"}})
36 return
37 }
38 w.Header().Set("Content-Type", "application/json")
39 _, _ = w.Write([]byte(resp))
40 }))
41 t.Cleanup(srv.Close)
42 return srv, &calls, responses
43}
44
45func TestIssueEndpointsRoundTrip(t *testing.T) {
46 srv, calls, responses := newIssuesServer(t)
47 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}`
48 responses["POST /api/v1/repos/o/r/issues"] = `{"number":8,"title":"new","state":"open","author":"rick","body":"the body","labels":[],"comments":[]}`
49 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"}]}`
50 responses["POST /api/v1/repos/o/r/issues/7/state"] = `{"number":7,"state":"closed","title":"bug"}`
51 responses["POST /api/v1/repos/o/r/issues/7/comments"] = `{"author":"rick","body":"hi"}`
52 responses["POST /api/v1/repos/o/r/issues/7/labels"] = ``
53 responses["POST /api/v1/repos/o/r/issues/7/milestone"] = ``
54 responses["POST /api/v1/repos/o/r/issues/7/assignees"] = ``
55 responses["DELETE /api/v1/repos/o/r/milestones/m1"] = ``
56 responses["GET /api/v1/repos/o/r/labels"] = `[{"id":"1","name":"bug","color":"d73a4a"}]`
57 responses["GET /api/v1/repos/o/r/milestones"] = `[{"id":"m1","title":"v1.0","state":"open","open_issues":2,"closed_issues":1}]`
58 responses["POST /api/v1/repos/o/r/milestones"] = `{"id":"m1","title":"v1.0","state":"open"}`
59 responses["PATCH /api/v1/repos/o/r/milestones/m1"] = `{"id":"m1","title":"v1.0","state":"closed"}`
60 c := New(srv.URL, "rickub_pat_x")
61 ctx := context.Background()
62
63 page, err := c.ListIssues(ctx, "o", "r", "open", 0, 0)
64 if err != nil || len(page.Items) != 1 || page.Items[0].Milestone.Title != "v1.0" || page.Items[0].Labels[0].Name != "bug" {
65 t.Fatalf("ListIssues: %+v err=%v", page, err)
66 }
67
68 created, err := c.CreateIssue(ctx, "o", "r", "new", "the body")
69 if err != nil || created.Number != 8 || created.Body != "the body" {
70 t.Fatalf("CreateIssue: %+v err=%v", created, err)
71 }
72
73 detail, err := c.GetIssue(ctx, "o", "r", 7)
74 if err != nil || len(detail.Comments) != 1 || len(detail.Assignees) != 1 || detail.Assignees[0].Handle != "rick" {
75 t.Fatalf("GetIssue: %+v err=%v", detail, err)
76 }
77
78 closed, err := c.SetIssueState(ctx, "o", "r", 7, "closed")
79 if err != nil || closed.State != "closed" {
80 t.Fatalf("SetIssueState: %+v err=%v", closed, err)
81 }
82
83 if _, err := c.CommentIssue(ctx, "o", "r", 7, "hi"); err != nil {
84 t.Fatalf("CommentIssue: %v", err)
85 }
86 if err := c.SetIssueLabels(ctx, "o", "r", 7, []string{"bug"}); err != nil {
87 t.Fatalf("SetIssueLabels: %v", err)
88 }
89 if err := c.SetIssueMilestone(ctx, "o", "r", 7, "v1.0"); err != nil {
90 t.Fatalf("SetIssueMilestone: %v", err)
91 }
92 if err := c.SetIssueAssignee(ctx, "o", "r", 7, "add", "rick"); err != nil {
93 t.Fatalf("SetIssueAssignee: %v", err)
94 }
95
96 labels, err := c.ListLabels(ctx, "o", "r")
97 if err != nil || len(labels) != 1 || labels[0].Color != "d73a4a" {
98 t.Fatalf("ListLabels: %+v err=%v", labels, err)
99 }
100
101 mses, err := c.ListMilestones(ctx, "o", "r", "open")
102 if err != nil || len(mses) != 1 || mses[0].OpenCount != 2 || mses[0].ClosedCount != 1 {
103 t.Fatalf("ListMilestones: %+v err=%v", mses, err)
104 }
105
106 if _, err := c.CreateMilestone(ctx, "o", "r", "v1.0", "", "2026-12-31"); err != nil {
107 t.Fatalf("CreateMilestone: %v", err)
108 }
109 updated, err := c.UpdateMilestone(ctx, "o", "r", "m1", map[string]any{"state": "closed"})
110 if err != nil || updated.State != "closed" {
111 t.Fatalf("UpdateMilestone: %+v err=%v", updated, err)
112 }
113
114 // The mutation bodies carry what the server expects.
115 want := map[string]recordedRequest{
116 "POST /api/v1/repos/o/r/issues": {Method: "POST", Path: "/api/v1/repos/o/r/issues", Body: `{"body":"the body","title":"new"}`},
117 "POST /api/v1/repos/o/r/issues/7/state": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/state", Body: `{"state":"closed"}`},
118 "POST /api/v1/repos/o/r/issues/7/labels": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/labels", Body: `{"labels":["bug"]}`},
119 "POST /api/v1/repos/o/r/issues/7/milestone": {Method: "POST", Path: "/api/v1/repos/o/r/issues/7/milestone", Body: `{"milestone":"v1.0"}`},
120 "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"}`},
121 "PATCH /api/v1/repos/o/r/milestones/m1": {Method: "PATCH", Path: "/api/v1/repos/o/r/milestones/m1", Body: `{"state":"closed"}`},
122 }
123 byKey := map[string]recordedRequest{}
124 for _, c := range *calls {
125 byKey[c.Method+" "+c.Path] = c
126 }
127 for key, w := range want {
128 if got := byKey[key]; got.Body != w.Body {
129 t.Errorf("%s body = %s, want %s", key, got.Body, w.Body)
130 }
131 }
132}
133
134func TestDeviceFlowClientRoundTrip(t *testing.T) {
135 var sawAuth bool
136 var tokenPathCalls int
137 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138 if h := r.Header.Get("Authorization"); h != "" {
139 sawAuth = true
140 }
141 switch {
142 case r.Method == "POST" && r.URL.Path == "/api/v1/device/code":
143 _, _ = 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}`))
144 case r.Method == "POST" && r.URL.Path == "/api/v1/device/token":
145 tokenPathCalls++
146 if tokenPathCalls == 1 {
147 w.WriteHeader(http.StatusBadRequest)
148 _, _ = w.Write([]byte(`{"error":{"code":"authorization_pending","message":"keep polling"}}`))
149 return
150 }
151 _, _ = w.Write([]byte(`{"access_token":"rickub_pat_minted","token_type":"bearer","scope":"all"}`))
152 default:
153 w.WriteHeader(http.StatusNotFound)
154 }
155 }))
156 defer srv.Close()
157
158 c := New(srv.URL, "") // tokenless: the device code is the credential
159 start, err := c.StartDeviceLogin(context.Background(), "all", "test client")
160 if err != nil || start.UserCode != "ABCD-EFGH" || start.Interval != 1 {
161 t.Fatalf("StartDeviceLogin: %+v err=%v", start, err)
162 }
163
164 // First poll: pending → *APIError with the machine code.
165 if _, err := c.PollDeviceToken(context.Background(), start.DeviceCode); err == nil {
166 t.Fatal("first poll should error")
167 } else {
168 var apiErr *APIError
169 if !errors.As(err, &apiErr) || apiErr.Code != "authorization_pending" {
170 t.Fatalf("pending error: %v", err)
171 }
172 }
173
174 // Second poll: minted token.
175 tok, err := c.PollDeviceToken(context.Background(), start.DeviceCode)
176 if err != nil || tok.AccessToken != "rickub_pat_minted" {
177 t.Fatalf("PollDeviceToken: %+v err=%v", tok, err)
178 }
179 if sawAuth {
180 t.Error("device endpoints must not send an Authorization header on a tokenless client")
181 }
182}