package cmd import ( "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // issueCmdServer stubs the pieces the `issue` and `milestone` commands touch. func issueCmdServer(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/api/v1/repos/ricktester/portal/issues": _ = json.NewEncoder(w).Encode(map[string]any{ "items": []map[string]any{ {"number": 1, "title": "build broke", "state": "open", "author": "rick", "labels": []map[string]any{{"name": "bug"}}, "milestone": map[string]any{"title": "v1.0"}}, }, "page": 1, "per_page": 30, "has_next": false, }) case r.Method == "POST" && r.URL.Path == "/api/v1/repos/ricktester/portal/issues": var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewEncoder(w).Encode(map[string]any{"number": 9, "title": body["title"], "state": "open"}) case r.Method == "POST" && r.URL.Path == "/api/v1/repos/ricktester/portal/issues/1/state": var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewEncoder(w).Encode(map[string]any{"number": 1, "state": body["state"], "title": "build broke"}) default: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"no"}}`)) } })) } func TestIssueListCreateClose(t *testing.T) { srv := issueCmdServer(t) defer srv.Close() out, _, err := execute(t, "issue", "list", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t") if err != nil { t.Fatalf("issue list: %v (out=%s)", err, out) } for _, want := range []string{"1", "open", "bug", "v1.0", "build broke"} { if !strings.Contains(out, want) { t.Errorf("issue list output missing %q: %s", want, out) } } out, _, err = execute(t, "issue", "create", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t", "-t", "a new one", "-b", "body text") if err != nil { t.Fatalf("issue create: %v (out=%s)", err, out) } if !strings.Contains(out, "Opened issue #9") { t.Errorf("issue create output: %s", out) } out, _, err = execute(t, "issue", "close", "1", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t") if err != nil { t.Fatalf("issue close: %v (out=%s)", err, out) } if !strings.Contains(out, "now closed") { t.Errorf("issue close output: %s", out) } // create without --title fails fast, client-side. if _, _, err := execute(t, "issue", "create", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t"); err == nil { t.Error("issue create without --title should fail") } } // runWatchServer serves a run that is queued for the first two GETs and // success afterwards (or failure, per wantStatus). func runWatchServer(t *testing.T, wantStatus string) *httptest.Server { t.Helper() calls := 0 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/repos/ricktester/portal/actions/runs/7" { w.WriteHeader(http.StatusNotFound) return } calls++ status := "queued" if calls > 2 { status = wantStatus } _ = json.NewEncoder(w).Encode(map[string]any{ "number": 7, "workflow": "CI", "status": status, "branch": "main", "head_sha": "abc12345", "jobs": []map[string]any{{"name": "build", "status": status, "steps": []map[string]any{{"ordinal": 1, "name": "compile", "status": status}}}}, }) })) } func TestRunWatchSuccessAndFailure(t *testing.T) { srv := runWatchServer(t, "success") defer srv.Close() out, _, err := execute(t, "run", "watch", "7", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t", "--interval", "5ms", "--timeout", "5s") if err != nil { t.Fatalf("watch success: %v (out=%s)", err, out) } if !strings.Contains(out, "finished: success") { t.Errorf("watch success output: %s", out) } srv2 := runWatchServer(t, "failure") defer srv2.Close() out, _, err = execute(t, "run", "watch", "7", "-R", "ricktester/portal", "--host", srv2.URL, "--token", "rickub_pat_t", "--interval", "5ms", "--timeout", "5s") var exitErr *runWatchExitError if err == nil { t.Fatal("watch failure should error") } if !errors.As(err, &exitErr) || exitErr.status != "failure" { t.Fatalf("watch failure error: %v", err) } if !strings.Contains(out, "finished: failure") { t.Errorf("watch failure output: %s", out) } } func TestRunWatchTimeout(t *testing.T) { srv := runWatchServer(t, "running") // never terminal defer srv.Close() _, _, err := execute(t, "run", "watch", "7", "-R", "ricktester/portal", "--host", srv.URL, "--token", "rickub_pat_t", "--interval", "5ms", "--timeout", "30ms") if err == nil || !strings.Contains(fmt.Sprint(err), "timed out") { t.Fatalf("watch timeout error: %v", err) } } // TestAuthLoginDeviceFlow drives `rickub auth login` end-to-end against a stub // host: code issuance, a pending poll, approval, whoami verification, and the // config write (isolated via XDG_CONFIG_HOME). func TestAuthLoginDeviceFlow(t *testing.T) { xdg := t.TempDir() t.Setenv("XDG_CONFIG_HOME", xdg) t.Setenv("RICKUB_HOST", "") t.Setenv("RICKUB_TOKEN", "") pollCalls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "POST" && r.URL.Path == "/api/v1/device/code": if h := r.Header.Get("Authorization"); h != "" { t.Errorf("device/code sent an Authorization header: %q", h) } _ = json.NewEncoder(w).Encode(map[string]any{ "device_code": "dc-123", "user_code": "ABCD-EFGH", "verification_url": "https://stub/login/device", "verification_uri_complete": "https://stub/login/device?user_code=ABCD-EFGH", "expires_in": 600, "interval": 1, }) case r.Method == "POST" && r.URL.Path == "/api/v1/device/token": pollCalls++ if pollCalls == 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"}`)) case r.Method == "GET" && r.URL.Path == "/api/v1/user": if got := r.Header.Get("Authorization"); got != "Bearer rickub_pat_minted" { t.Errorf("whoami auth = %q", got) } _ = json.NewEncoder(w).Encode(map[string]any{"handle": "ricktester"}) default: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"no"}}`)) } })) defer srv.Close() out, _, err := execute(t, "auth", "login", "--host", srv.URL, "--no-browser") if err != nil { t.Fatalf("auth login: %v (out=%s)", err, out) } for _, want := range []string{"ABCD-EFGH", "https://stub/login/device", "Approved", "Logged in", "ricktester"} { if !strings.Contains(out, want) { t.Errorf("auth login output missing %q:\n%s", want, out) } } // The minted token landed in the isolated config file. b, err := os.ReadFile(filepath.Join(xdg, "rickub", "config.yaml")) if err != nil { t.Fatalf("read config: %v", err) } if !strings.Contains(string(b), "rickub_pat_minted") || !strings.Contains(string(b), srv.URL) { t.Errorf("config file content: %s", string(b)) } if pollCalls != 2 { t.Errorf("poll calls = %d, want 2 (pending then minted)", pollCalls) } }