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.
325 lines
10 KiB
Go
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) }
|