From 6d406f378f25f93a9a263071ba58725ef02b6087 Mon Sep 17 00:00:00 2001 From: sofq Date: Sat, 18 Apr 2026 16:11:43 +0700 Subject: [PATCH 1/4] ci: ignore generated code in codecov reporting cmd/generated (51k LOC of OpenAPI-generated command code) and cmd/gendocs are not meaningful for coverage tracking. Excluding them restores the coverage ratio from ~9% back to the true test coverage of hand-written code. --- codecov.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..4cc2406 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,3 @@ +ignore: + - "cmd/generated/**" + - "cmd/gendocs/**" From 9754ba9029de4140584a90aeb6e51adb1ebee7ce Mon Sep 17 00:00:00 2001 From: sofq Date: Sat, 18 Apr 2026 16:35:33 +0700 Subject: [PATCH 2/4] test: cover defensive branches in cmd and template to reach 100% - Add tests for cmd/root.go PersistentPreRunE/PostRunE branches (preset expansion, policy check, audit logger setup, help func) - Add tests for cmd/raw.go --body - stdin path - Add tests for cmd/batch.go stdin stat/read error branches - Add tests for internal/template loadUserTemplates missing-dir path - Add tests for Execute() success/AlreadyWrittenError/generic error paths - Ignore main.go in codecov (3-line entry point) Brings local coverage to 99.9% with only main.go uncovered (now ignored). --- cmd/batch_test.go | 71 ++++++++ cmd/raw_test.go | 40 +++++ cmd/root_test.go | 267 +++++++++++++++++++++++++++++ codecov.yml | 1 + internal/template/template_test.go | 21 +++ 5 files changed, 400 insertions(+) diff --git a/cmd/batch_test.go b/cmd/batch_test.go index 91317e5..0a46837 100644 --- a/cmd/batch_test.go +++ b/cmd/batch_test.go @@ -4058,3 +4058,74 @@ func TestBatchTemplateApply_LookupError(t *testing.T) { t.Errorf("expected ExitError for lookup failure, got %d", code) } } + +// TestBatch_StdinStatError covers the branch where os.Stdin.Stat() fails +// (replaced with a closed file descriptor). +func TestBatch_StdinStatError(t *testing.T) { + var stdout, stderr bytes.Buffer + c := newTestClient("http://should-not-be-called", &stdout, &stderr) + + ctx := client.NewContext(t.Context(), c) + cmd := batchCmd + cmd.ResetFlags() + cmd.Flags().String("input", "", "") + cmd.Flags().Int("max-batch", 50, "") + cmd.SetContext(ctx) + + // Create a pipe and close it to force a stat error. + pr, pw, _ := os.Pipe() + pw.Close() + pr.Close() + + oldStdin := os.Stdin + os.Stdin = pr + defer func() { os.Stdin = oldStdin }() + + err := runBatch(cmd, nil) + if err == nil { + t.Fatal("expected error when stdin is closed") + } + aw, ok := err.(*jrerrors.AlreadyWrittenError) + if !ok { + t.Fatalf("expected AlreadyWrittenError, got %T", err) + } + if aw.Code != jrerrors.ExitValidation { + t.Errorf("expected ExitValidation, got %d", aw.Code) + } +} + +// TestBatch_StdinReadError covers io.ReadAll failure. A directory fd passes +// Stat() (mode is ModeDir, not ModeCharDevice) but Read returns EISDIR. +func TestBatch_StdinReadError(t *testing.T) { + var stdout, stderr bytes.Buffer + c := newTestClient("http://should-not-be-called", &stdout, &stderr) + + ctx := client.NewContext(t.Context(), c) + cmd := batchCmd + cmd.ResetFlags() + cmd.Flags().String("input", "", "") + cmd.Flags().Int("max-batch", 50, "") + cmd.SetContext(ctx) + + dir, err := os.Open(t.TempDir()) + if err != nil { + t.Fatalf("open dir: %v", err) + } + defer dir.Close() + + oldStdin := os.Stdin + os.Stdin = dir + defer func() { os.Stdin = oldStdin }() + + err = runBatch(cmd, nil) + if err == nil { + t.Fatal("expected error when stdin is a directory") + } + aw, ok := err.(*jrerrors.AlreadyWrittenError) + if !ok { + t.Fatalf("expected AlreadyWrittenError, got %T", err) + } + if aw.Code != jrerrors.ExitValidation { + t.Errorf("expected ExitValidation, got %d", aw.Code) + } +} diff --git a/cmd/raw_test.go b/cmd/raw_test.go index 0ebf00a..e76f78f 100644 --- a/cmd/raw_test.go +++ b/cmd/raw_test.go @@ -531,3 +531,43 @@ func TestRawCmd_BodyAtWithoutFilename(t *testing.T) { t.Errorf("expected ExitValidation, got %d", aw.Code) } } + +// TestRawCmd_StdinBody exercises --body - (explicit stdin). +func TestRawCmd_StdinBody(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := make([]byte, 1024) + n, _ := r.Body.Read(body) + if !strings.Contains(string(body[:n]), "piped") { + t.Errorf("expected piped body, got %q", string(body[:n])) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"ok":true}`) + })) + defer ts.Close() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + go func() { + w.Write([]byte(`{"from":"piped"}`)) + w.Close() + }() + + var stdout, stderr bytes.Buffer + c := newTestClient(ts.URL, &stdout, &stderr) + ctx := client.NewContext(t.Context(), c) + cmd := &cobra.Command{Use: "raw"} + cmd.Flags().String("body", "", "") + cmd.Flags().StringArray("query", nil, "") + _ = cmd.Flags().Set("body", "-") + cmd.SetContext(ctx) + + if err := runRaw(cmd, []string{"POST", "/test"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go index a89f29f..9528658 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -3,9 +3,11 @@ package cmd import ( "bytes" "encoding/json" + "os" "strings" "testing" + jrerrors "github.com/sofq/jira-cli/internal/errors" "github.com/spf13/cobra" ) @@ -156,3 +158,268 @@ func TestMergeCommand_HandWrittenSubNotDuplicated(t *testing.T) { } } } + +// --- PersistentPreRunE branch coverage --- + +func TestPreRunE_SkippedCommandReturnsNil(t *testing.T) { + cmd := &cobra.Command{Use: "configure"} + if err := rootCmd.PersistentPreRunE(cmd, nil); err != nil { + t.Fatalf("expected skip for 'configure', got %v", err) + } +} + +func TestPreRunE_SkippedParentReturnsNil(t *testing.T) { + parent := &cobra.Command{Use: "completion"} + child := &cobra.Command{Use: "bash"} + parent.AddCommand(child) + if err := rootCmd.PersistentPreRunE(child, nil); err != nil { + t.Fatalf("expected skip via parent, got %v", err) + } +} + +// makePreRunCmd attaches a disposable subcommand to rootCmd so persistent +// flags are inherited. Caller must call cleanup() to detach. +func makePreRunCmd(t *testing.T, name string) (*cobra.Command, func()) { + t.Helper() + sub := &cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}} + rootCmd.AddCommand(sub) + sub.InheritedFlags() + sub.SetContext(t.Context()) + return sub, func() { rootCmd.RemoveCommand(sub) } +} + +func writeConfig(t *testing.T, body string) string { + t.Helper() + f := t.TempDir() + "/config.json" + if err := os.WriteFile(f, []byte(body), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return f +} + +func TestPreRunE_ConfigResolveError(t *testing.T) { + t.Setenv("JR_CONFIG_PATH", writeConfig(t, "not-json")) + cmd, cleanup := makePreRunCmd(t, "preruntest-cfg") + defer cleanup() + + err := rootCmd.PersistentPreRunE(cmd, nil) + if err == nil { + t.Fatal("expected config resolve error") + } + aw, ok := err.(*jrerrors.AlreadyWrittenError) + if !ok || aw.Code != jrerrors.ExitError { + t.Fatalf("expected ExitError AlreadyWrittenError, got %v", err) + } +} + +func TestPreRunE_MissingBaseURL(t *testing.T) { + t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"auth":{"type":"basic","username":"u","token":"t"}}}}`)) + t.Setenv("JR_BASE_URL", "") + cmd, cleanup := makePreRunCmd(t, "preruntest-nourl") + defer cleanup() + + err := rootCmd.PersistentPreRunE(cmd, nil) + if err == nil { + t.Fatal("expected missing base_url error") + } + aw, ok := err.(*jrerrors.AlreadyWrittenError) + if !ok || aw.Code != jrerrors.ExitError { + t.Fatalf("expected ExitError, got %v", err) + } +} + +func TestPreRunE_UnknownPreset(t *testing.T) { + t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"base_url":"http://x","auth":{"type":"basic","username":"u","token":"t"}}}}`)) + cmd, cleanup := makePreRunCmd(t, "preruntest-badpreset") + defer cleanup() + _ = cmd.Flags().Parse([]string{"--preset", "does-not-exist"}) + + err := rootCmd.PersistentPreRunE(cmd, nil) + if err == nil { + t.Fatal("expected unknown preset error") + } + aw, ok := err.(*jrerrors.AlreadyWrittenError) + if !ok || aw.Code != jrerrors.ExitValidation { + t.Fatalf("expected ExitValidation, got %v", err) + } +} + +func TestPreRunE_PresetApplied(t *testing.T) { + t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"base_url":"http://x","auth":{"type":"basic","username":"u","token":"t"}}}}`)) + cmd, cleanup := makePreRunCmd(t, "preruntest-preset") + defer cleanup() + // 'agent' is a builtin preset that sets fields and jq. + _ = cmd.Flags().Parse([]string{"--preset", "agent"}) + + if err := rootCmd.PersistentPreRunE(cmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPreRunE_PolicyBlocksOperation(t *testing.T) { + t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"base_url":"http://x","auth":{"type":"basic","username":"u","token":"t"},"denied_operations":["preruntest-blocked"]}}}`)) + cmd, cleanup := makePreRunCmd(t, "preruntest-blocked") + defer cleanup() + + err := rootCmd.PersistentPreRunE(cmd, nil) + if err == nil { + t.Fatal("expected policy error") + } + aw, ok := err.(*jrerrors.AlreadyWrittenError) + if !ok || aw.Code != jrerrors.ExitValidation { + t.Fatalf("expected ExitValidation, got %v", err) + } +} + +func TestPreRunE_InvalidPolicyConfig(t *testing.T) { + // allowed + denied both set → invalid policy. + t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"base_url":"http://x","auth":{"type":"basic","username":"u","token":"t"},"allowed_operations":["*"],"denied_operations":["*"]}}}`)) + cmd, cleanup := makePreRunCmd(t, "preruntest-badpol") + defer cleanup() + + err := rootCmd.PersistentPreRunE(cmd, nil) + if err == nil { + t.Fatal("expected invalid policy error") + } + aw, ok := err.(*jrerrors.AlreadyWrittenError) + if !ok || aw.Code != jrerrors.ExitValidation { + t.Fatalf("expected ExitValidation, got %v", err) + } +} + +func TestPreRunE_AuditEnabledDefaultPath(t *testing.T) { + t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"base_url":"http://x","auth":{"type":"basic","username":"u","token":"t"}}}}`)) + auditDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", auditDir) + t.Setenv("HOME", auditDir) + + cmd, cleanup := makePreRunCmd(t, "preruntest-audit") + defer cleanup() + _ = cmd.Flags().Parse([]string{"--audit"}) + + if err := rootCmd.PersistentPreRunE(cmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Invoke PostRunE to close the audit logger. + _ = rootCmd.PersistentPostRunE(cmd, nil) +} + +func TestPreRunE_AuditOpenFailure(t *testing.T) { + t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"base_url":"http://x","auth":{"type":"basic","username":"u","token":"t"}}}}`)) + // Point audit-file to a directory (can't be opened as log file). + dir := t.TempDir() + cmd, cleanup := makePreRunCmd(t, "preruntest-audfail") + defer cleanup() + _ = cmd.Flags().Parse([]string{"--audit-file", dir}) + + // Should not return error — audit failure is non-fatal. + if err := rootCmd.PersistentPreRunE(cmd, nil); err != nil { + t.Fatalf("audit failure should be non-fatal, got %v", err) + } +} + +func TestPostRunE_ClosesAuditLogger(t *testing.T) { + // Happy path is exercised by TestPreRunE_AuditEnabledDefaultPath. + // Here exercise the "no client in context" branch. + cmd := &cobra.Command{Use: "anything"} + cmd.SetContext(t.Context()) + if err := rootCmd.PersistentPostRunE(cmd, nil); err != nil { + t.Fatalf("expected nil for no-client path, got %v", err) + } +} + +// --- init() coverage: the custom HelpFunc --- + +func TestRootHelp_JSONForRoot(t *testing.T) { + var buf bytes.Buffer + origOut := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + rootCmd.HelpFunc()(rootCmd, nil) + w.Close() + os.Stdout = origOut + buf.ReadFrom(r) + + if !strings.Contains(buf.String(), "hint") { + t.Errorf("expected hint in root help JSON, got: %s", buf.String()) + } +} + +func TestRootHelp_SubcommandWritesToStderr(t *testing.T) { + sub, cleanup := makePreRunCmd(t, "helpsubtest") + defer cleanup() + // Should not panic; writes to stderr. + rootCmd.HelpFunc()(sub, nil) +} + +// TestExecute exercises the Execute() entry point happy path (--version). +// The error return path is covered implicitly via rootCmd.Execute() failures +// in PersistentPreRunE tests, which exercise the same error handling code +// when PreRunE returns AlreadyWrittenError. +func TestExecute(t *testing.T) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = []string{"jr", "--version"} + // Another test may have called rootCmd.SetArgs(); clear it so cobra + // falls back to os.Args. + rootCmd.SetArgs(os.Args[1:]) + defer rootCmd.SetArgs(nil) + + code := Execute() + if code != 0 { + t.Errorf("expected exit 0 for --version, got %d", code) + } +} + +// TestExecute_GenericError covers the non-AlreadyWrittenError return branch +// in Execute() by passing an invalid value for a typed flag (duration parse +// error) to a subcommand that doesn't gate on config. +func TestExecute_GenericError(t *testing.T) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + // 'schema' is a skipClientCommand, so PreRunE returns nil and cobra itself + // reports the flag parse error (plain error, not AlreadyWrittenError). + os.Args = []string{"jr", "schema", "--cache", "not-a-duration"} + rootCmd.SetArgs(os.Args[1:]) + defer rootCmd.SetArgs(nil) + + origStderr := os.Stderr + devnull, _ := os.Open(os.DevNull) + os.Stderr = devnull + defer func() { os.Stderr = origStderr; devnull.Close() }() + + code := Execute() + if code == 0 { + t.Error("expected non-zero exit for invalid flag value") + } +} + +// TestExecute_PropagatesAlreadyWrittenError verifies that Execute() returns the +// embedded exit code when rootCmd.Execute() returns an AlreadyWrittenError. +// Uses a subcommand wired to fail inside PersistentPreRunE. +func TestExecute_PropagatesAlreadyWrittenError(t *testing.T) { + // Write a malformed config so PreRunE returns AlreadyWrittenError(ExitError). + cfg := t.TempDir() + "/config.json" + if err := os.WriteFile(cfg, []byte("not-json"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("JR_CONFIG_PATH", cfg) + + origArgs := os.Args + defer func() { os.Args = origArgs }() + // schema is a skipClientCommand, so won't hit PreRunE. Use a real command. + // myself is a generated command that requires a client, so PreRunE runs. + os.Args = []string{"jr", "myself", "get-current-user"} + rootCmd.SetArgs(os.Args[1:]) + defer rootCmd.SetArgs(nil) + + origStderr := os.Stderr + devnull, _ := os.Open(os.DevNull) + os.Stderr = devnull + defer func() { os.Stderr = origStderr; devnull.Close() }() + + code := Execute() + if code == 0 { + t.Error("expected non-zero exit when config fails to parse") + } +} diff --git a/codecov.yml b/codecov.yml index 4cc2406..3add6ee 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,4 @@ ignore: - "cmd/generated/**" - "cmd/gendocs/**" + - "main.go" diff --git a/internal/template/template_test.go b/internal/template/template_test.go index f48c730..4ae319b 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -474,6 +474,27 @@ func TestList_WithUserTemplate(t *testing.T) { } } +// TestLoadUserTemplates_MissingDir verifies that loadUserTemplates returns +// (nil, nil) when the user templates directory does not exist — this is the +// expected fast-path when a user has never created any templates. +func TestLoadUserTemplates_MissingDir(t *testing.T) { + tmpDir := t.TempDir() + missing := filepath.Join(tmpDir, "does-not-exist") + + origDir := userTemplatesDir + userTemplatesDir = func() string { return missing } + defer func() { userTemplatesDir = origDir }() + + // Lookup of a builtin should succeed (user dir missing is not an error). + _, found, err := Lookup("bug-report") + if err != nil { + t.Fatalf("unexpected error when user dir missing: %v", err) + } + if !found { + t.Fatal("expected builtin 'bug-report' to be found") + } +} + // TestLoadUserTemplates_UnreadableDir verifies that loadUserTemplates (via // Lookup) returns an error when the templates directory path exists but is not // a directory (e.g. it is a regular file). os.ReadDir on a file fails with an From e2a0a7e3f3fac688cae50b23570590a45c1d26a5 Mon Sep 17 00:00:00 2001 From: sofq Date: Sat, 18 Apr 2026 16:42:25 +0700 Subject: [PATCH 3/4] test: fix errcheck lint violations --- cmd/raw_test.go | 4 ++-- cmd/root_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/raw_test.go b/cmd/raw_test.go index e76f78f..e94d619 100644 --- a/cmd/raw_test.go +++ b/cmd/raw_test.go @@ -554,8 +554,8 @@ func TestRawCmd_StdinBody(t *testing.T) { defer func() { os.Stdin = origStdin }() go func() { - w.Write([]byte(`{"from":"piped"}`)) - w.Close() + _, _ = w.Write([]byte(`{"from":"piped"}`)) + _ = w.Close() }() var stdout, stderr bytes.Buffer diff --git a/cmd/root_test.go b/cmd/root_test.go index 9528658..01472ba 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -338,7 +338,7 @@ func TestRootHelp_JSONForRoot(t *testing.T) { rootCmd.HelpFunc()(rootCmd, nil) w.Close() os.Stdout = origOut - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) if !strings.Contains(buf.String(), "hint") { t.Errorf("expected hint in root help JSON, got: %s", buf.String()) From d3f36715a3487efeeb67a7f93ac013cad0be7ee8 Mon Sep 17 00:00:00 2001 From: sofq Date: Sat, 18 Apr 2026 16:53:47 +0700 Subject: [PATCH 4/4] test: cover schemaCmd/presetList/versionCmd RunE closures These anonymous closures were not exercised by existing tests, so codecov reported them as 0-hit even though named functions showed 100%. Add direct invocations of RunE for each command, including jq/pretty/error branches. Also cover the preset.Lookup error path in PersistentPreRunE and ensure makePreRunCmd resets rootCmd's persistent flags between tests to prevent state pollution across test runs. --- cmd/preset_test.go | 67 +++++++++++++++++++++++++ cmd/root_test.go | 83 ++++++++++++++++++++++++++++-- cmd/schema_cmd_test.go | 111 +++++++++++++++++++++++++++++++++++++++++ cmd/version_test.go | 43 ++++++++++++++++ 4 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 cmd/version_test.go diff --git a/cmd/preset_test.go b/cmd/preset_test.go index b819257..36cdbef 100644 --- a/cmd/preset_test.go +++ b/cmd/preset_test.go @@ -2,8 +2,12 @@ package cmd import ( "encoding/json" + "os" + "path/filepath" "strings" "testing" + + "github.com/spf13/cobra" ) func TestPresetList_OutputsJSON(t *testing.T) { @@ -41,3 +45,66 @@ func TestPresetList_OutputsJSON(t *testing.T) { } } } + +// Exercise the preset list command with --jq and --pretty flags and failure paths. + +func TestPresetList_WithJQ(t *testing.T) { + cmd := &cobra.Command{Use: "list"} + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + _ = cmd.Flags().Set("jq", "[.[].name]") + + if err := presetListCmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPresetList_WithInvalidJQ(t *testing.T) { + cmd := &cobra.Command{Use: "list"} + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + _ = cmd.Flags().Set("jq", "[.invalid") + + err := presetListCmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected jq error") + } +} + +func TestPresetList_WithPretty(t *testing.T) { + cmd := &cobra.Command{Use: "list"} + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + _ = cmd.Flags().Set("pretty", "true") + + if err := presetListCmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPresetList_PropagatesListError(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("APPDATA", home) + for _, p := range []string{ + home + "/jr/presets.json", + home + "/Library/Application Support/jr/presets.json", + } { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("not-json"), 0o600); err != nil { + t.Fatal(err) + } + } + + cmd := &cobra.Command{Use: "list"} + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + + err := presetListCmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected error from preset.List") + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go index 01472ba..a11b728 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -4,11 +4,13 @@ import ( "bytes" "encoding/json" "os" + "path/filepath" "strings" "testing" jrerrors "github.com/sofq/jira-cli/internal/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) func TestRootHelp_NoHTMLEscaping(t *testing.T) { @@ -178,14 +180,22 @@ func TestPreRunE_SkippedParentReturnsNil(t *testing.T) { } // makePreRunCmd attaches a disposable subcommand to rootCmd so persistent -// flags are inherited. Caller must call cleanup() to detach. +// flags are inherited. Caller must call cleanup() to detach and reset any +// persistent-flag state that parent tests may have mutated. func makePreRunCmd(t *testing.T, name string) (*cobra.Command, func()) { t.Helper() sub := &cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}} rootCmd.AddCommand(sub) sub.InheritedFlags() sub.SetContext(t.Context()) - return sub, func() { rootCmd.RemoveCommand(sub) } + return sub, func() { + rootCmd.RemoveCommand(sub) + // Reset persistent flags on rootCmd so subsequent tests see defaults. + rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + _ = f.Value.Set(f.DefValue) + f.Changed = false + }) + } } func writeConfig(t *testing.T, body string) string { @@ -246,16 +256,81 @@ func TestPreRunE_UnknownPreset(t *testing.T) { func TestPreRunE_PresetApplied(t *testing.T) { t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"base_url":"http://x","auth":{"type":"basic","username":"u","token":"t"}}}}`)) + + // Install a user preset with both Fields and JQ so both override + // branches in PreRunE are exercised. Write to every platform path so this + // works regardless of OS. + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("APPDATA", home) + body := []byte(`{"mypreset":{"fields":"key,summary","jq":".key"}}`) + for _, p := range []string{ + home + "/jr/presets.json", + home + "/Library/Application Support/jr/presets.json", + } { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, body, 0o600); err != nil { + t.Fatal(err) + } + } + cmd, cleanup := makePreRunCmd(t, "preruntest-preset") defer cleanup() - // 'agent' is a builtin preset that sets fields and jq. - _ = cmd.Flags().Parse([]string{"--preset", "agent"}) + _ = cmd.Flags().Parse([]string{"--preset", "mypreset"}) if err := rootCmd.PersistentPreRunE(cmd, nil); err != nil { t.Fatalf("unexpected error: %v", err) } } +func TestPreRunE_PresetLookupError(t *testing.T) { + t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"base_url":"http://x","auth":{"type":"basic","username":"u","token":"t"}}}}`)) + writeBrokenUserPresets(t) + + cmd, cleanup := makePreRunCmd(t, "preruntest-presetlookuperr") + defer cleanup() + _ = cmd.Flags().Parse([]string{"--preset", "anything"}) + + err := rootCmd.PersistentPreRunE(cmd, nil) + if err == nil { + t.Fatal("expected preset lookup error") + } + aw, ok := err.(*jrerrors.AlreadyWrittenError) + if !ok || aw.Code != jrerrors.ExitError { + t.Fatalf("expected ExitError, got %v", err) + } +} + +// writeBrokenUserPresets puts malformed JSON at every possible user-config +// location so preset.Lookup / preset.List return an error regardless of OS. +func writeBrokenUserPresets(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("APPDATA", home) + + // Linux/XDG: $XDG_CONFIG_HOME/jr/presets.json + // macOS: $HOME/Library/Application Support/jr/presets.json + // Windows: %APPDATA%/jr/presets.json + paths := []string{ + home + "/jr/presets.json", + home + "/Library/Application Support/jr/presets.json", + } + for _, p := range paths { + dir := filepath.Dir(p) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("not-json"), 0o600); err != nil { + t.Fatal(err) + } + } +} + func TestPreRunE_PolicyBlocksOperation(t *testing.T) { t.Setenv("JR_CONFIG_PATH", writeConfig(t, `{"default_profile":"p","profiles":{"p":{"base_url":"http://x","auth":{"type":"basic","username":"u","token":"t"},"denied_operations":["preruntest-blocked"]}}}`)) cmd, cleanup := makePreRunCmd(t, "preruntest-blocked") diff --git a/cmd/schema_cmd_test.go b/cmd/schema_cmd_test.go index a08ae71..e7b9fd4 100644 --- a/cmd/schema_cmd_test.go +++ b/cmd/schema_cmd_test.go @@ -137,3 +137,114 @@ func TestMarshalNoEscape_Error(t *testing.T) { t.Error("expected error marshaling a channel") } } + +// --- schemaCmd.RunE coverage --- + +func TestSchemaCmd_Compact(t *testing.T) { + cmd := &cobra.Command{Use: "schema"} + cmd.Flags().Bool("list", false, "") + cmd.Flags().Bool("compact", false, "") + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + + if err := schemaCmd.RunE(cmd, nil); err != nil { + t.Fatalf("default (compact) RunE failed: %v", err) + } +} + +func TestSchemaCmd_CompactFlag(t *testing.T) { + cmd := &cobra.Command{Use: "schema"} + cmd.Flags().Bool("list", false, "") + cmd.Flags().Bool("compact", false, "") + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + _ = cmd.Flags().Set("compact", "true") + + if err := schemaCmd.RunE(cmd, []string{"issue"}); err != nil { + t.Fatalf("--compact RunE failed: %v", err) + } +} + +func TestSchemaCmd_ListFlag(t *testing.T) { + cmd := &cobra.Command{Use: "schema"} + cmd.Flags().Bool("list", false, "") + cmd.Flags().Bool("compact", false, "") + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + _ = cmd.Flags().Set("list", "true") + + if err := schemaCmd.RunE(cmd, nil); err != nil { + t.Fatalf("--list RunE failed: %v", err) + } +} + +func TestSchemaCmd_ResourceOnly(t *testing.T) { + cmd := &cobra.Command{Use: "schema"} + cmd.Flags().Bool("list", false, "") + cmd.Flags().Bool("compact", false, "") + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + + if err := schemaCmd.RunE(cmd, []string{"issue"}); err != nil { + t.Fatalf("RunE with resource failed: %v", err) + } +} + +func TestSchemaCmd_ResourceNotFound(t *testing.T) { + cmd := &cobra.Command{Use: "schema"} + cmd.Flags().Bool("list", false, "") + cmd.Flags().Bool("compact", false, "") + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + + err := schemaCmd.RunE(cmd, []string{"definitely-nonexistent-xyz"}) + if err == nil { + t.Fatal("expected not_found error") + } +} + +func TestSchemaCmd_ResourceVerb(t *testing.T) { + cmd := &cobra.Command{Use: "schema"} + cmd.Flags().Bool("list", false, "") + cmd.Flags().Bool("compact", false, "") + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + + // Find a real resource+verb from the generated ops. + allOps := generated.AllSchemaOps() + if len(allOps) == 0 { + t.Skip("no generated ops available") + } + op := allOps[0] + + if err := schemaCmd.RunE(cmd, []string{op.Resource, op.Verb}); err != nil { + t.Fatalf("RunE with resource+verb failed: %v", err) + } +} + +func TestSchemaCmd_ResourceVerbNotFound(t *testing.T) { + cmd := &cobra.Command{Use: "schema"} + cmd.Flags().Bool("list", false, "") + cmd.Flags().Bool("compact", false, "") + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + + err := schemaCmd.RunE(cmd, []string{"issue", "definitely-nonexistent-verb"}) + if err == nil { + t.Fatal("expected not_found error") + } +} + +// --- schemaOutput --pretty path --- + +func TestSchemaOutput_Pretty(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + _ = cmd.Flags().Set("pretty", "true") + + data, _ := json.Marshal(map[string]string{"a": "b"}) + if err := schemaOutput(cmd, data); err != nil { + t.Fatalf("schemaOutput with --pretty failed: %v", err) + } +} diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 0000000..e51c34e --- /dev/null +++ b/cmd/version_test.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// TestVersionCmd exercises versionCmd.RunE which emits the current Version as JSON. +func TestVersionCmd(t *testing.T) { + cmd := &cobra.Command{Use: "version"} + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + + if err := versionCmd.RunE(cmd, nil); err != nil { + t.Fatalf("versionCmd.RunE failed: %v", err) + } +} + +// TestVersionCmd_MarshalError forces marshalNoEscape to return an error by +// swapping it with a failing stub to cover the err-handling branch. +func TestVersionCmd_MarshalError(t *testing.T) { + orig := marshalNoEscape + defer func() { marshalNoEscape = orig }() + marshalNoEscape = func(any) ([]byte, error) { + return nil, errStub + } + + cmd := &cobra.Command{Use: "version"} + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("pretty", false, "") + + err := versionCmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected marshal error to propagate") + } +} + +var errStub = &stubError{msg: "stub marshal error"} + +type stubError struct{ msg string } + +func (e *stubError) Error() string { return e.msg }