package cli import ( "encoding/json" "os" "path/filepath" "strconv" "strings" "testing" "git.knownelement.com/ukrrs/mopac-redmine-go/internal/fakeredmine" ) const testKey = "fake-redmine-key-0123456789" func boot(t *testing.T) *fakeredmine.Server { t.Helper() srv := fakeredmine.New(testKey) t.Cleanup(srv.Close) t.Setenv("MRED_URL", srv.URL) t.Setenv("MRED_KEY", testKey) return srv } func run(t *testing.T, args ...string) (string, string, int) { t.Helper() var out, errb strings.Builder code := Run(args, &out, &errb) return out.String(), errb.String(), code } func TestUsageErrors(t *testing.T) { boot(t) tests := []struct { name string args []string }{ {"no args", nil}, {"unknown command", []string{"frobnicate"}}, {"issue alone", []string{"issue"}}, {"issue list without -p", []string{"issue", "list"}}, {"issue show without id", []string{"issue", "show"}}, {"issue create without subject", []string{"issue", "create", "-p", "MOPAC"}}, {"version create without name", []string{"version", "create", "-p", "MOPAC"}}, {"relation create without type", []string{"relation", "create", "1", "2"}}, {"relation create bad type", []string{"relation", "create", "1", "2", "--type", "destroys"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { out, errb, code := run(t, tt.args...) if code != 1 { t.Fatalf("code = %d (out %q, err %q), want 1", code, out, errb) } if out != "" { t.Errorf("usage error wrote to stdout: %q", out) } if errb == "" { t.Errorf("no diagnostic on stderr") } }) } } func TestConfigErrorIsExitOne(t *testing.T) { boot(t) t.Setenv("MRED_URL", "") t.Setenv("MRED_KEY", "") _, errb, code := run(t, "issue", "list", "-p", "MOPAC") if code != 1 { t.Fatalf("code = %d, want 1 (config)", code) } if !strings.Contains(errb, "MRED_URL") { t.Fatalf("stderr = %q", errb) } } func TestIssueCreateFullFlags(t *testing.T) { srv := boot(t) srv.AddVersion(fakeredmine.Version{Name: "Beta", Status: "open"}) srv.AddCategory("Quota & Backpressure") parent := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "parent", StatusID: 1, TrackerID: 4, PriorityID: 2}) out, errb, code := run(t, "issue", "create", "-p", "MOPAC", "-s", "Quota: per-identity usage accounting", "--desc", "-", "--tracker", "feature", "--priority", "immediate", "--category", "quota & backpressure", "--version", "beta", "--due", "2026-08-31", "--parent", itoa(parent), "--est", "8", "--note", "seed note", "-o", "json", ) if code != 0 { t.Fatalf("code = %d, stderr = %q", code, errb) } var got struct { Issue struct { ID int `json:"id"` Subject string `json:"subject"` Tracker struct{ Name string } `json:"tracker"` Priority struct{ Name string } `json:"priority"` FixedVersion struct{ Name string } `json:"fixed_version"` } `json:"issue"` } if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("stdout not json: %v (%q)", err, out) } i := got.Issue if i.Subject != "Quota: per-identity usage accounting" || i.Tracker.Name != "Feature" || i.Priority.Name != "Immediate" || i.FixedVersion.Name != "Beta" { t.Errorf("issue = %+v", i) } stored, ok := srv.Issue(i.ID) if !ok || stored.EstimatedHours == nil || *stored.EstimatedHours != 8 || stored.ParentIssueID != parent { t.Errorf("stored = %+v", stored) } } func TestIssueCreateDescFile(t *testing.T) { srv := boot(t) path := filepath.Join(t.TempDir(), "desc.md") os.WriteFile(path, []byte("## Scope\n- body from file"), 0o600) _, errb, code := run(t, "issue", "create", "-p", "MOPAC", "-s", "from file", "--desc", path) if code != 0 { t.Fatalf("code = %d, stderr = %q", code, errb) } for _, r := range srv.Requests() { if strings.Contains(r.Path, "/issues.json") && r.Method == "POST" { var p struct { Issue struct { Description string `json:"description"` } `json:"issue"` } json.Unmarshal([]byte(r.Body), &p) if p.Issue.Description != "## Scope\n- body from file" { t.Fatalf("description = %q", p.Issue.Description) } return } } t.Fatal("no POST recorded") } func TestIssueListTextAndFilters(t *testing.T) { srv := boot(t) verID := srv.AddVersion(fakeredmine.Version{Name: "Beta", Status: "open"}) open := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "open one", StatusID: 1, TrackerID: 2, PriorityID: 2, FixedVersionID: verID}) done := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "done one", StatusID: 3, TrackerID: 2, PriorityID: 2}) out, errb, code := run(t, "issue", "list", "-p", "MOPAC") if code != 0 { t.Fatalf("code = %d, stderr = %q", code, errb) } if !strings.Contains(out, itoa(open)) || strings.Contains(out, itoa(done)) || !strings.Contains(out, "open one") { t.Errorf("text list = %q", out) } if !strings.Contains(out, "Beta") { t.Errorf("list omits version: %q", out) } out, _, code = run(t, "issue", "list", "-p", "MOPAC", "--status", "all") if code != 0 || !strings.Contains(out, itoa(done)) { t.Errorf("status all = %q code %d", out, code) } out, _, code = run(t, "issue", "list", "-p", "MOPAC", "--version", "Beta") if code != 0 || !strings.Contains(out, itoa(open)) || strings.Contains(out, itoa(done)) { t.Errorf("version filter = %q code %d", out, code) } out, _, code = run(t, "issue", "list", "-p", "MOPAC", "-o", "json") if code != 0 { t.Fatalf("json list code %d", code) } var arr struct { Issues []struct { ID int `json:"id"` } `json:"issues"` } if err := json.Unmarshal([]byte(out), &arr); err != nil || len(arr.Issues) != 1 || arr.Issues[0].ID != open { t.Errorf("json list = %q err %v", out, err) } } func TestIssueShowWithJournals(t *testing.T) { srv := boot(t) id := srv.AddIssue(fakeredmine.Issue{ ProjectID: "MOPAC", Subject: "shown", StatusID: 2, TrackerID: 2, PriorityID: 3, Description: "body text", Journals: []fakeredmine.JournalEntry{{ID: 1, Notes: "first note"}}, }) out, errb, code := run(t, "issue", "show", itoa(id), "--with", "journals") if code != 0 { t.Fatalf("code = %d, stderr = %q", code, errb) } for _, want := range []string{itoa(id), "shown", "body text", "In Progress", "first note"} { if !strings.Contains(out, want) { t.Errorf("show output missing %q:\n%s", want, out) } } } func TestIssueUpdateNoteStatusDoneRatio(t *testing.T) { srv := boot(t) srv.AddVersion(fakeredmine.Version{Name: "Beta", Status: "open"}) srv.AddCategory("Briefing") id := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "target", StatusID: 2, TrackerID: 2, PriorityID: 2}) out, errb, code := run(t, "issue", "update", itoa(id), "--status", "done", "--done-ratio", "100", "--category", "briefing", "--version", "Beta", "--priority", "immediate", "--note", "REPORT delivered") if code != 0 { t.Fatalf("code = %d, stderr = %q", code, errb) } if !strings.Contains(out, itoa(id)) { t.Errorf("update confirmation = %q", out) } stored, _ := srv.Issue(id) if stored.StatusID != 3 || stored.DoneRatio != 100 || stored.CategoryID == 0 || stored.FixedVersionID == 0 || stored.PriorityID != 5 { t.Errorf("stored = %+v", stored) } if len(stored.Journals) != 1 || stored.Journals[0].Notes != "REPORT delivered" { t.Errorf("journals = %+v", stored.Journals) } } func TestVersionCommands(t *testing.T) { boot(t) out, errb, code := run(t, "version", "create", "-p", "MOPAC", "-n", "Beta", "--due", "2026-08-31", "--status", "open") if code != 0 { t.Fatalf("create code = %d, stderr = %q", code, errb) } if !strings.Contains(out, "Beta") { t.Errorf("create out = %q", out) } out, _, code = run(t, "version", "list", "-p", "MOPAC", "-o", "json") if code != 0 { t.Fatalf("list code %d", code) } var arr struct { Versions []struct { Name string `json:"name"` Status string `json:"status"` } `json:"versions"` } if err := json.Unmarshal([]byte(out), &arr); err != nil || len(arr.Versions) != 1 || arr.Versions[0].Name != "Beta" || arr.Versions[0].Status != "open" { t.Errorf("json = %q err %v", out, err) } } func TestVersionUpdate(t *testing.T) { tests := []struct { name string args []string // ID placeholder "@" replaced with the seeded id wantOut string // substring expected on stdout wantVer fakeredmine.Version wantCode int }{ { name: "status flip", args: []string{"@", "--status", "closed"}, wantOut: "updated version #", wantVer: fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "closed"}, }, { name: "due change", args: []string{"@", "--due", "2026-09-15"}, wantVer: fakeredmine.Version{Name: "Beta", DueDate: "2026-09-15", Status: "open"}, }, { name: "name change", args: []string{"@", "--name", "Beta 2"}, wantVer: fakeredmine.Version{Name: "Beta 2", DueDate: "2026-08-31", Status: "open"}, }, { name: "combined flags", args: []string{"@", "--status", "closed", "--due", "2026-09-15", "--name", "Gamma"}, wantVer: fakeredmine.Version{Name: "Gamma", DueDate: "2026-09-15", Status: "closed"}, }, { name: "no flags is a usage error", args: []string{"@"}, wantCode: 1, }, { name: "bad status is a usage error", args: []string{"@", "--status", "deferred"}, wantCode: 1, }, { name: "non-numeric id is a usage error", args: []string{"beta", "--status", "closed"}, wantCode: 1, }, { name: "unknown version id is exit 2", args: []string{"424242", "--status", "closed"}, wantCode: 2, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv := boot(t) id := srv.AddVersion(fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "open"}) args := make([]string, len(tt.args)) for i, a := range tt.args { if a == "@" { args[i] = itoa(id) } else { args[i] = a } } out, errb, code := run(t, append([]string{"version", "update"}, args...)...) wantCode := tt.wantCode if wantCode == 0 { if !strings.Contains(out, tt.wantOut) || !strings.Contains(out, itoa(id)) { t.Errorf("out = %q, want %q + id %d", out, tt.wantOut, id) } got, ok := srv.Version(id) if !ok { t.Fatalf("version %d vanished", id) } if got.Name != tt.wantVer.Name || got.DueDate != tt.wantVer.DueDate || got.Status != tt.wantVer.Status { t.Errorf("stored = %+v, want %+v", got, tt.wantVer) } } else { if out != "" { t.Errorf("usage error wrote to stdout: %q", out) } if errb == "" { t.Errorf("no diagnostic on stderr") } if tt.wantCode == 2 && !strings.Contains(errb, "http 404") { t.Errorf("stderr = %q, want http 404", errb) } } if code != wantCode { t.Fatalf("code = %d, want %d (stderr %q)", code, wantCode, errb) } }) } } func TestVersionUpdateJSONRefetches(t *testing.T) { srv := boot(t) id := srv.AddVersion(fakeredmine.Version{Name: "Beta", DueDate: "2026-08-31", Status: "open"}) out, errb, code := run(t, "version", "update", itoa(id), "--status", "closed", "-o", "json") if code != 0 { t.Fatalf("code = %d, stderr = %q", code, errb) } var got struct { Version struct { ID int `json:"id"` Name string `json:"name"` DueDate string `json:"due_date"` Status string `json:"status"` } `json:"version"` } if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("stdout not json: %v (%q)", err, out) } v := got.Version if v.ID != id || v.Name != "Beta" || v.DueDate != "2026-08-31" || v.Status != "closed" { t.Errorf("refetched version = %+v", v) } } func TestCategoryCommands(t *testing.T) { boot(t) _, errb, code := run(t, "category", "create", "-p", "MOPAC", "-n", "Secrets") if code != 0 { t.Fatalf("create code = %d, stderr = %q", code, errb) } out, _, _ := run(t, "category", "list", "-p", "MOPAC") if !strings.Contains(out, "Secrets") { t.Errorf("list = %q", out) } } func TestRelationCreate(t *testing.T) { srv := boot(t) a := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "a", StatusID: 1, TrackerID: 2, PriorityID: 2}) b := srv.AddIssue(fakeredmine.Issue{ProjectID: "MOPAC", Subject: "b", StatusID: 1, TrackerID: 2, PriorityID: 2}) out, errb, code := run(t, "relation", "create", itoa(a), itoa(b), "--type", "blocks") if code != 0 { t.Fatalf("code = %d, stderr = %q", code, errb) } if !strings.Contains(out, "blocks") || !strings.Contains(out, itoa(b)) { t.Errorf("out = %q", out) } } func TestAPIErrorIsExitTwo(t *testing.T) { srv := boot(t) srv.Fail = &fakeredmine.FailSpec{Status: 404, Body: `{"errors":["gone %s"]}`} out, errb, code := run(t, "issue", "show", "999") if code != 2 { t.Fatalf("code = %d, want 2", code) } if out != "" { t.Errorf("stdout on API error: %q", out) } lines := strings.Split(strings.TrimRight(errb, "\n"), "\n") if len(lines) != 1 || !strings.Contains(lines[0], "http 404") { t.Fatalf("stderr = %q, want single parseable line with http 404", errb) } } func TestKeyNeverPrinted(t *testing.T) { srv := fakeredmine.New(testKey) t.Cleanup(srv.Close) t.Setenv("MRED_URL", srv.URL) t.Setenv("MRED_KEY", "wrong-key-xyz") _, errb, code := run(t, "issue", "list", "-p", "MOPAC") if code != 2 { t.Fatalf("code = %d, want 2", code) } if strings.Contains(errb, "wrong-key-xyz") || strings.Contains(errb, testKey) { t.Fatalf("stderr leaks a key: %q", errb) } if strings.Count(errb, "\n") != 1 { // one diagnostic line + trailing newline t.Fatalf("stderr not one line: %q", errb) } } func TestHelpExitsZero(t *testing.T) { boot(t) out, _, code := run(t, "help") if code != 0 || !strings.Contains(out, "issue") { t.Fatalf("help code = %d out = %q", code, out) } } func itoa(n int) string { return strconv.Itoa(n) }