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/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/raw_test.go b/cmd/raw_test.go index 0ebf00a..e94d619 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..a11b728 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -3,10 +3,14 @@ package cmd 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) { @@ -156,3 +160,341 @@ 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 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) + // 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 { + 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"}}}}`)) + + // 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() + _ = 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") + 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/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 } diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..3add6ee --- /dev/null +++ b/codecov.yml @@ -0,0 +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