Files
mopac-redmine-go/internal/cli/cli_test.go
T
mrcharles d98f0aeb93 Add the mred CLI over the library, with flags accepted after positionals
issue list/show/create/update, version list/create, category
list/create, relation create. Names resolve case-insensitively against
server enumerations; -o json everywhere; exit 0/1/2 with one-line
stderr carrying the http status for API failures.
2026-08-29 06:51:54 -05:00

325 lines
10 KiB
Go

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 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) }