| Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 13h ago | 1 | package api |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | type recordedRequest struct { |
| 15 | Method string |
| 16 | Path string |
| 17 | Body string |
| 18 | } |
| 19 | |
| 20 | func 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 | |
| 45 | func 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 | |
| 134 | func 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 | } |