package cli import ( "bytes" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "git.knownelement.com/ukrrs/mopac-gitea-go/internal/fakegitea" ) const fakeToken = "fake-gitea-token-0123456789" // run boots a fake Gitea, points the CLI at it via env, and runs one // command; it returns the exit code plus captured output. func run(t *testing.T, srv *fakegitea.Server, args ...string) (int, string, string) { t.Helper() t.Setenv("GITEA_URL", srv.URL) t.Setenv("GITEA_KEY", fakeToken) var out, errb bytes.Buffer code := Run(args, &out, &errb) return code, out.String(), errb.String() } func newServer(t *testing.T) *fakegitea.Server { t.Helper() srv := fakegitea.New(fakeToken, "ukrrs") t.Cleanup(srv.Close) return srv } func TestHelpAndUsage(t *testing.T) { srv := newServer(t) tests := []struct { name string args []string code int }{ {name: "help", args: []string{"help"}, code: 0}, {name: "no args", args: nil, code: 1}, {name: "unknown command", args: []string{"frobnicate"}, code: 1}, {name: "unknown subcommand", args: []string{"repo", "frob"}, code: 1}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { code, _, errOut := run(t, srv, tt.args...) if code != tt.code { t.Fatalf("code = %d, want %d (stderr: %s)", code, tt.code, errOut) } }) } code, out, _ := run(t, srv, "help") if code != 0 || !strings.Contains(out, "mgit repo create") || !strings.Contains(out, "GITEA_URL") { t.Fatalf("help output = %q", out) } } func TestRepoLifecycle(t *testing.T) { srv := newServer(t) code, out, errOut := run(t, srv, "repo", "create", "-n", "vertical-x", "--desc", "one vertical", "--private", "--auto-init") if code != 0 { t.Fatalf("repo create: code=%d stderr=%s", code, errOut) } if !strings.Contains(out, "ukrrs/vertical-x") || !strings.Contains(out, "private") { t.Fatalf("out = %q", out) } code, out, errOut = run(t, srv, "repo", "create", "-n", "vertical-y", "-o", "json") if code != 0 { t.Fatalf("repo create json: %s", errOut) } var repo struct { FullName string `json:"full_name"` Private bool `json:"private"` } if err := json.Unmarshal([]byte(out), &repo); err != nil { t.Fatalf("json out: %v (%s)", err, out) } if repo.FullName != "ukrrs/vertical-y" || repo.Private { t.Fatalf("json = %+v", repo) } code, out, errOut = run(t, srv, "repo", "list") if code != 0 { t.Fatalf("repo list: %s", errOut) } if !strings.Contains(out, "ukrrs/vertical-x") || !strings.Contains(out, "ukrrs/vertical-y") { t.Fatalf("list = %q", out) } code, out, errOut = run(t, srv, "repo", "list", "--owner", "other") if code != 0 { t.Fatalf("repo list other: %s", errOut) } if strings.Contains(out, "ukrrs/") { t.Fatalf("list other = %q", out) } code, out, errOut = run(t, srv, "repo", "show", "ukrrs/vertical-x") if code != 0 || !strings.Contains(out, "one vertical") { t.Fatalf("repo show: code=%d out=%q err=%s", code, out, errOut) } // Duplicate create -> exit 2, one-line stderr with the http code. code, out, errOut = run(t, srv, "repo", "create", "-n", "vertical-x") if code != 2 { t.Fatalf("dup create code = %d, want 2", code) } if !strings.Contains(errOut, "http 409") || strings.Count(strings.TrimRight(errOut, "\n"), "\n") != 0 { t.Fatalf("dup stderr = %q", errOut) } } func TestRepoCreateMissingName(t *testing.T) { srv := newServer(t) code, _, errOut := run(t, srv, "repo", "create") if code != 1 || !strings.Contains(errOut, "-n") { t.Fatalf("code=%d err=%q", code, errOut) } } func TestBranchList(t *testing.T) { srv := newServer(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") code, out, errOut := run(t, srv, "branch", "list", "ukrrs/MOPAC") if code != 0 { t.Fatalf("branch list: %s", errOut) } if !strings.Contains(out, "main") || !strings.Contains(out, "feat/quota") { t.Fatalf("out = %q", out) } code, out, errOut = run(t, srv, "branch", "list", "ukrrs/MOPAC", "-o", "json") if code != 0 { t.Fatalf("branch list json: %s", errOut) } var branches []struct { Name string `json:"name"` } if err := json.Unmarshal([]byte(out), &branches); err != nil || len(branches) != 2 { t.Fatalf("json = %s (%v)", out, err) } if code, _, errOut = run(t, srv, "branch", "list", "ukrrs/absent"); code != 2 || !strings.Contains(errOut, "http 404") { t.Fatalf("missing repo: code=%d err=%q", code, errOut) } } func TestStatusCommands(t *testing.T) { srv := newServer(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main") sha := srv.AddBranch("ukrrs", "MOPAC", "feat/quota") code, out, errOut := run(t, srv, "status", "create", "ukrrs/MOPAC", sha, "--state", "success", "--context", "ci/lint", "--description", "clean") if code != 0 { t.Fatalf("status create: %s", errOut) } if !strings.Contains(out, "success") || !strings.Contains(out, "ci/lint") { t.Fatalf("out = %q", out) } // Invalid state is a usage error before any request. if code, _, errOut = run(t, srv, "status", "create", "ukrrs/MOPAC", sha, "--state", "green"); code != 1 { t.Fatalf("invalid state code = %d err=%q", code, errOut) } code, out, errOut = run(t, srv, "status", "list", "ukrrs/MOPAC", "feat/quota") if code != 0 { t.Fatalf("status list: %s", errOut) } if !strings.Contains(out, "success") || !strings.Contains(out, "ci/lint") { t.Fatalf("out = %q", out) } } func TestPullRequestFlow(t *testing.T) { srv := newServer(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") bodyFile := filepath.Join(t.TempDir(), "body.md") os.WriteFile(bodyFile, []byte("## Scope\n- quota accounting"), 0o600) code, out, errOut := run(t, srv, "pr", "create", "ukrrs/MOPAC", "--title", "Quota accounting", "--body", bodyFile, "--base", "main", "--head", "feat/quota", "-o", "json") if code != 0 { t.Fatalf("pr create: %s", errOut) } var pr struct { Number int64 `json:"number"` State string `json:"state"` } if err := json.Unmarshal([]byte(out), &pr); err != nil || pr.Number != 1 || pr.State != "open" { t.Fatalf("json = %s (%v)", out, err) } code, out, errOut = run(t, srv, "pr", "list", "ukrrs/MOPAC") if code != 0 || !strings.Contains(out, "Quota accounting") || !strings.Contains(out, "#1") { t.Fatalf("pr list: code=%d out=%q err=%s", code, out, errOut) } code, out, errOut = run(t, srv, "pr", "show", "ukrrs/MOPAC", "1") if code != 0 || !strings.Contains(out, "quota accounting") || !strings.Contains(out, "main..feat/quota") { t.Fatalf("pr show: code=%d out=%q err=%s", code, out, errOut) } code, out, errOut = run(t, srv, "pr", "merge", "ukrrs/MOPAC", "1", "--do", "squash") if code != 0 || !strings.Contains(out, "#1") || !strings.Contains(out, "squash") { t.Fatalf("pr merge: code=%d out=%q err=%s", code, out, errOut) } // Merged: the open list is empty and re-merge fails with 409. code, out, _ = run(t, srv, "pr", "list", "ukrrs/MOPAC") if code != 0 || strings.Contains(out, "Quota accounting") { t.Fatalf("pr list after merge = %q", out) } if code, _, errOut = run(t, srv, "pr", "merge", "ukrrs/MOPAC", "1"); code != 2 || !strings.Contains(errOut, "http 409") { t.Fatalf("re-merge: code=%d err=%q", code, errOut) } } func TestPullRequestUsageErrors(t *testing.T) { srv := newServer(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") tests := []struct { name string args []string }{ {"missing title", []string{"pr", "create", "ukrrs/MOPAC", "--base", "main", "--head", "feat/quota"}}, {"missing base", []string{"pr", "create", "ukrrs/MOPAC", "--title", "t", "--head", "feat/quota"}}, {"missing head", []string{"pr", "create", "ukrrs/MOPAC", "--title", "t", "--base", "main"}}, {"bad repo arg", []string{"pr", "create", "MOPAC", "--title", "t", "--base", "main", "--head", "x"}}, {"non-numeric index", []string{"pr", "merge", "ukrrs/MOPAC", "abc"}}, {"bad merge style", []string{"pr", "merge", "ukrrs/MOPAC", "1", "--do", "blend"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { code, _, errOut := run(t, srv, tt.args...) if code != 1 { t.Fatalf("code = %d, want usage error 1 (stderr: %s)", code, errOut) } }) } } func TestFlagsAfterPositionals(t *testing.T) { srv := newServer(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") code, _, errOut := run(t, srv, "pr", "create", "ukrrs/MOPAC", "--base", "main", "--head", "feat/quota", "--title", "flags after positional") if code != 0 { t.Fatalf("pr create: %s", errOut) } } func TestTokenNeverInOutput(t *testing.T) { srv := newServer(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main") t.Setenv("GITEA_URL", srv.URL) t.Setenv("GITEA_KEY", fakeToken) var out, errb bytes.Buffer srv.Token = "rotated-" + fakeToken // force a 401 body that echoes the token code := Run([]string{"repo", "list"}, &out, &errb) if code != 2 { t.Fatalf("code = %d, want 2", code) } if strings.Contains(out.String()+errb.String(), fakeToken) { t.Fatalf("token leaked: out=%q err=%q", out.String(), errb.String()) } } func TestMissingConfigIsUsageError(t *testing.T) { t.Setenv("GITEA_URL", "") t.Setenv("GITEA_KEY", "") var out, errb bytes.Buffer code := Run([]string{"repo", "list", "--config", filepath.Join(t.TempDir(), "absent")}, &out, &errb) if code != 1 || !strings.Contains(errb.String(), "GITEA_URL") { t.Fatalf("code=%d err=%q", code, errb.String()) } } func TestLooseConfigRefused(t *testing.T) { srv := newServer(t) path := filepath.Join(t.TempDir(), "loose.env") os.WriteFile(path, []byte("GITEA_URL="+srv.URL+"\nGITEA_KEY="+fakeToken+"\n"), 0o644) t.Setenv("GITEA_URL", "") t.Setenv("GITEA_KEY", "") var out, errb bytes.Buffer code := Run([]string{"repo", "list", "--config", path}, &out, &errb) if code != 1 || !strings.Contains(errb.String(), "insecure mode") { t.Fatalf("code=%d err=%q", code, errb.String()) } } func TestBodyFromStdinDash(t *testing.T) { srv := newServer(t) srv.AddRepo("ukrrs", "MOPAC", "main", "main", "feat/quota") t.Setenv("GITEA_URL", srv.URL) t.Setenv("GITEA_KEY", fakeToken) var out, errb bytes.Buffer old := os.Stdin r, w, _ := os.Pipe() w.WriteString("body via stdin\n") w.Close() os.Stdin = r defer func() { os.Stdin = old; time.Sleep(0) }() code := Run([]string{"pr", "create", "ukrrs/MOPAC", "--title", "stdin body", "--body", "-", "--base", "main", "--head", "feat/quota"}, &out, &errb) os.Stdin = old if code != 0 { t.Fatalf("pr create stdin: code=%d err=%s", code, errb.String()) } stored, ok := srv.PullRequest("ukrrs", "MOPAC", 1) if !ok || stored.Body != "body via stdin\n" { t.Fatalf("stored body = %q ok=%v", stored.Body, ok) } }