From a49022ffd63f959996b984d02073bceef918d3f4 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 31 Jan 2026 19:22:42 -0500 Subject: [PATCH 1/2] feat: add REST API commands for Phase 8 Add CLI commands for Salesforce REST API operations: - query: Execute SOQL queries with pagination and queryAll support - record: CRUD operations (get, create, update, delete) - search: SOSL text search across objects - object: List and describe SObjects and fields - limits: Display org API limits Also adds Search method and SearchResult type to api package. Closes #18 --- api/client.go | 16 ++ api/types.go | 43 ++++ cmd/sfdc/main.go | 12 ++ internal/cmd/limitscmd/limits.go | 129 ++++++++++++ internal/cmd/limitscmd/limits_test.go | 163 ++++++++++++++ internal/cmd/objectcmd/describe.go | 70 ++++++ internal/cmd/objectcmd/fields.go | 105 +++++++++ internal/cmd/objectcmd/list.go | 125 +++++++++++ internal/cmd/objectcmd/object.go | 28 +++ internal/cmd/objectcmd/object_test.go | 240 +++++++++++++++++++++ internal/cmd/querycmd/query.go | 202 ++++++++++++++++++ internal/cmd/querycmd/query_test.go | 216 +++++++++++++++++++ internal/cmd/recordcmd/create.go | 108 ++++++++++ internal/cmd/recordcmd/delete.go | 76 +++++++ internal/cmd/recordcmd/get.go | 112 ++++++++++ internal/cmd/recordcmd/record.go | 29 +++ internal/cmd/recordcmd/record_test.go | 293 ++++++++++++++++++++++++++ internal/cmd/recordcmd/update.go | 64 ++++++ internal/cmd/searchcmd/search.go | 140 ++++++++++++ internal/cmd/searchcmd/search_test.go | 185 ++++++++++++++++ 20 files changed, 2356 insertions(+) create mode 100644 internal/cmd/limitscmd/limits.go create mode 100644 internal/cmd/limitscmd/limits_test.go create mode 100644 internal/cmd/objectcmd/describe.go create mode 100644 internal/cmd/objectcmd/fields.go create mode 100644 internal/cmd/objectcmd/list.go create mode 100644 internal/cmd/objectcmd/object.go create mode 100644 internal/cmd/objectcmd/object_test.go create mode 100644 internal/cmd/querycmd/query.go create mode 100644 internal/cmd/querycmd/query_test.go create mode 100644 internal/cmd/recordcmd/create.go create mode 100644 internal/cmd/recordcmd/delete.go create mode 100644 internal/cmd/recordcmd/get.go create mode 100644 internal/cmd/recordcmd/record.go create mode 100644 internal/cmd/recordcmd/record_test.go create mode 100644 internal/cmd/recordcmd/update.go create mode 100644 internal/cmd/searchcmd/search.go create mode 100644 internal/cmd/searchcmd/search_test.go diff --git a/api/client.go b/api/client.go index 59e97ca..077d2c1 100644 --- a/api/client.go +++ b/api/client.go @@ -336,3 +336,19 @@ func (c *Client) DeleteRecord(ctx context.Context, objectName, recordID string) func (c *Client) RecordURL(recordID string) string { return fmt.Sprintf("%s/%s", c.InstanceURL, recordID) } + +// Search executes a SOSL search and returns the results +func (c *Client) Search(ctx context.Context, sosl string) (*SearchResult, error) { + path := fmt.Sprintf("/search?q=%s", url.QueryEscape(sosl)) + body, err := c.Get(ctx, path) + if err != nil { + return nil, err + } + + var result SearchResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse search result: %w", err) + } + + return &result, nil +} diff --git a/api/types.go b/api/types.go index 579c6b5..f1a207b 100644 --- a/api/types.go +++ b/api/types.go @@ -239,3 +239,46 @@ type LimitInfo struct { Max int `json:"Max"` Remaining int `json:"Remaining"` } + +// SearchResult represents the result of a SOSL search +type SearchResult struct { + SearchRecords []SearchRecord `json:"searchRecords"` +} + +// SearchRecord represents a single search result record +type SearchRecord struct { + Attributes SObjectAttributes `json:"attributes"` + ID string `json:"Id"` + Fields map[string]interface{} `json:"-"` +} + +// UnmarshalJSON custom unmarshaler for SearchRecord to capture all fields +func (s *SearchRecord) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + s.Fields = make(map[string]interface{}) + + for key, value := range raw { + switch key { + case "attributes": + if err := json.Unmarshal(value, &s.Attributes); err != nil { + return err + } + case "Id": + if err := json.Unmarshal(value, &s.ID); err != nil { + return err + } + default: + var v interface{} + if err := json.Unmarshal(value, &v); err != nil { + return err + } + s.Fields[key] = v + } + } + + return nil +} diff --git a/cmd/sfdc/main.go b/cmd/sfdc/main.go index 800b58b..2ab2d4f 100644 --- a/cmd/sfdc/main.go +++ b/cmd/sfdc/main.go @@ -8,7 +8,12 @@ import ( "github.com/open-cli-collective/salesforce-cli/internal/cmd/completion" "github.com/open-cli-collective/salesforce-cli/internal/cmd/configcmd" "github.com/open-cli-collective/salesforce-cli/internal/cmd/initcmd" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/limitscmd" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/objectcmd" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/querycmd" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/recordcmd" "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/searchcmd" ) // Exit codes @@ -33,5 +38,12 @@ func run() error { configcmd.Register(rootCmd, opts) completion.Register(rootCmd, opts) + // REST API commands + querycmd.Register(rootCmd, opts) + recordcmd.Register(rootCmd, opts) + searchcmd.Register(rootCmd, opts) + objectcmd.Register(rootCmd, opts) + limitscmd.Register(rootCmd, opts) + return rootCmd.Execute() } diff --git a/internal/cmd/limitscmd/limits.go b/internal/cmd/limitscmd/limits.go new file mode 100644 index 0000000..8011663 --- /dev/null +++ b/internal/cmd/limitscmd/limits.go @@ -0,0 +1,129 @@ +// Package limitscmd provides the limits command for viewing org API limits. +package limitscmd + +import ( + "context" + "fmt" + "sort" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/api" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +// Register registers the limits command with the root command. +func Register(parent *cobra.Command, opts *root.Options) { + parent.AddCommand(NewCommand(opts)) +} + +// NewCommand creates the limits command. +func NewCommand(opts *root.Options) *cobra.Command { + var show string + + cmd := &cobra.Command{ + Use: "limits", + Short: "Display org API limits", + Long: `Display the current Salesforce org's API limits and usage. + +Examples: + sfdc limits + sfdc limits -o json + sfdc limits --show DailyApiRequests`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runLimits(cmd.Context(), opts, show) + }, + } + + cmd.Flags().StringVar(&show, "show", "", "Show only a specific limit by name") + + return cmd +} + +func runLimits(ctx context.Context, opts *root.Options, show string) error { + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + limits, err := client.GetLimits(ctx) + if err != nil { + return fmt.Errorf("failed to get limits: %w", err) + } + + // If showing a specific limit + if show != "" { + return renderSingleLimit(opts, limits, show) + } + + return renderLimits(opts, limits) +} + +func renderSingleLimit(opts *root.Options, limits api.Limits, name string) error { + v := opts.View() + + limit, ok := limits[name] + if !ok { + return fmt.Errorf("limit %q not found", name) + } + + if opts.Output == "json" { + return v.JSON(map[string]interface{}{ + "name": name, + "max": limit.Max, + "remaining": limit.Remaining, + "used": limit.Max - limit.Remaining, + }) + } + + used := limit.Max - limit.Remaining + pct := float64(0) + if limit.Max > 0 { + pct = float64(used) / float64(limit.Max) * 100 + } + + v.Info("%s", name) + v.Info(" Max: %d", limit.Max) + v.Info(" Remaining: %d", limit.Remaining) + v.Info(" Used: %d (%.1f%%)", used, pct) + + return nil +} + +func renderLimits(opts *root.Options, limits api.Limits) error { + v := opts.View() + + if opts.Output == "json" { + return v.JSON(limits) + } + + // Sort limit names for consistent output + names := make([]string, 0, len(limits)) + for name := range limits { + names = append(names, name) + } + sort.Strings(names) + + headers := []string{"Limit", "Max", "Remaining", "Used", "Usage %"} + rows := make([][]string, 0, len(names)) + + for _, name := range names { + limit := limits[name] + used := limit.Max - limit.Remaining + pct := float64(0) + if limit.Max > 0 { + pct = float64(used) / float64(limit.Max) * 100 + } + + rows = append(rows, []string{ + name, + fmt.Sprintf("%d", limit.Max), + fmt.Sprintf("%d", limit.Remaining), + fmt.Sprintf("%d", used), + fmt.Sprintf("%.1f%%", pct), + }) + } + + return v.Table(headers, rows) +} diff --git a/internal/cmd/limitscmd/limits_test.go b/internal/cmd/limitscmd/limits_test.go new file mode 100644 index 0000000..61861d3 --- /dev/null +++ b/internal/cmd/limitscmd/limits_test.go @@ -0,0 +1,163 @@ +package limitscmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/salesforce-cli/api" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func TestLimitsCommand(t *testing.T) { + limits := api.Limits{ + "DailyApiRequests": api.LimitInfo{Max: 100000, Remaining: 99500}, + "DailyBulkApiRequests": api.LimitInfo{Max: 10000, Remaining: 10000}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/limits") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(limits) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := NewCommand(opts) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "DailyApiRequests") + assert.Contains(t, output, "100000") + assert.Contains(t, output, "99500") +} + +func TestLimitsCommand_ShowSpecific(t *testing.T) { + limits := api.Limits{ + "DailyApiRequests": api.LimitInfo{Max: 100000, Remaining: 99500}, + "DailyBulkApiRequests": api.LimitInfo{Max: 10000, Remaining: 10000}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(limits) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := NewCommand(opts) + cmd.SetArgs([]string{"--show", "DailyApiRequests"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "DailyApiRequests") + assert.Contains(t, output, "Max:") + assert.Contains(t, output, "Remaining:") + assert.Contains(t, output, "Used:") +} + +func TestLimitsCommand_ShowNotFound(t *testing.T) { + limits := api.Limits{ + "DailyApiRequests": api.LimitInfo{Max: 100000, Remaining: 99500}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(limits) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + opts := &root.Options{ + Output: "table", + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := NewCommand(opts) + cmd.SetArgs([]string{"--show", "NonExistentLimit"}) + + err = cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestLimitsCommand_JSONOutput(t *testing.T) { + limits := api.Limits{ + "DailyApiRequests": api.LimitInfo{Max: 100000, Remaining: 99500}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(limits) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "json", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := NewCommand(opts) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + var result api.Limits + err = json.Unmarshal(stdout.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, 100000, result["DailyApiRequests"].Max) +} diff --git a/internal/cmd/objectcmd/describe.go b/internal/cmd/objectcmd/describe.go new file mode 100644 index 0000000..7e699b0 --- /dev/null +++ b/internal/cmd/objectcmd/describe.go @@ -0,0 +1,70 @@ +package objectcmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func newDescribeCommand(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe ", + Short: "Describe an object's metadata", + Long: `Display detailed metadata about a Salesforce object. + +Examples: + sfdc object describe Account + sfdc object describe Account -o json + sfdc object describe MyCustomObject__c`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDescribe(cmd.Context(), opts, args[0]) + }, + } + + return cmd +} + +func runDescribe(ctx context.Context, opts *root.Options, objectName string) error { + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + desc, err := client.DescribeSObject(ctx, objectName) + if err != nil { + return fmt.Errorf("failed to describe object: %w", err) + } + + v := opts.View() + + if opts.Output == "json" { + return v.JSON(desc) + } + + // Display object info + v.Info("Object: %s", desc.Name) + v.Info("Label: %s (%s)", desc.Label, desc.LabelPlural) + if desc.KeyPrefix != "" { + v.Info("Key Prefix: %s", desc.KeyPrefix) + } + v.Info("") + + // Display capabilities + v.Info("Capabilities:") + v.Info(" Custom: %v", desc.Custom) + v.Info(" Createable: %v", desc.Createable) + v.Info(" Updateable: %v", desc.Updateable) + v.Info(" Deletable: %v", desc.Deletable) + v.Info(" Queryable: %v", desc.Queryable) + v.Info(" Searchable: %v", desc.Searchable) + v.Info("") + + // Display field count + v.Info("Fields: %d (use 'sfdc object fields %s' to list)", len(desc.Fields), objectName) + + return nil +} diff --git a/internal/cmd/objectcmd/fields.go b/internal/cmd/objectcmd/fields.go new file mode 100644 index 0000000..f267877 --- /dev/null +++ b/internal/cmd/objectcmd/fields.go @@ -0,0 +1,105 @@ +package objectcmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/api" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func newFieldsCommand(opts *root.Options) *cobra.Command { + var requiredOnly bool + + cmd := &cobra.Command{ + Use: "fields ", + Short: "List fields for an object", + Long: `List all fields for a Salesforce object. + +Examples: + sfdc object fields Account + sfdc object fields Account --required-only + sfdc object fields Contact -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runFields(cmd.Context(), opts, args[0], requiredOnly) + }, + } + + cmd.Flags().BoolVar(&requiredOnly, "required-only", false, "Show only required fields") + + return cmd +} + +func runFields(ctx context.Context, opts *root.Options, objectName string, requiredOnly bool) error { + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + desc, err := client.DescribeSObject(ctx, objectName) + if err != nil { + return fmt.Errorf("failed to describe object: %w", err) + } + + v := opts.View() + + // Filter fields if needed + fields := desc.Fields + if requiredOnly { + filtered := make([]api.Field, 0) + for _, f := range fields { + // Required = not nillable AND createable (can be set on create) + if !f.Nillable && f.Createable { + filtered = append(filtered, f) + } + } + fields = filtered + } + + if opts.Output == "json" { + return v.JSON(fields) + } + + if len(fields) == 0 { + v.Info("No fields found") + return nil + } + + headers := []string{"Name", "Label", "Type", "Length", "Required", "Custom"} + rows := make([][]string, 0, len(fields)) + + for _, f := range fields { + required := "No" + if !f.Nillable && f.Createable { + required = "Yes" + } + custom := "No" + if f.Custom { + custom = "Yes" + } + + length := "" + if f.Length > 0 { + length = fmt.Sprintf("%d", f.Length) + } + + rows = append(rows, []string{ + f.Name, + f.Label, + f.Type, + length, + required, + custom, + }) + } + + if err := v.Table(headers, rows); err != nil { + return err + } + + v.Info("\n%d field(s)", len(fields)) + return nil +} diff --git a/internal/cmd/objectcmd/list.go b/internal/cmd/objectcmd/list.go new file mode 100644 index 0000000..c02f942 --- /dev/null +++ b/internal/cmd/objectcmd/list.go @@ -0,0 +1,125 @@ +package objectcmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func newListCommand(opts *root.Options) *cobra.Command { + var customOnly bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List all objects in the org", + Long: `List all Salesforce objects (SObjects) in the org. + +Examples: + sfdc object list + sfdc object list --custom-only + sfdc object list -o json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(cmd.Context(), opts, customOnly) + }, + } + + cmd.Flags().BoolVar(&customOnly, "custom-only", false, "Show only custom objects") + + return cmd +} + +func runList(ctx context.Context, opts *root.Options, customOnly bool) error { + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + resp, err := client.GetSObjects(ctx) + if err != nil { + return fmt.Errorf("failed to get objects: %w", err) + } + + v := opts.View() + + // Filter if needed + objects := resp.SObjects + if customOnly { + filtered := make([]struct { + Name string + Label string + LabelPlural string + KeyPrefix string + Custom bool + Queryable bool + }, 0) + for _, obj := range objects { + if obj.Custom { + filtered = append(filtered, struct { + Name string + Label string + LabelPlural string + KeyPrefix string + Custom bool + Queryable bool + }{ + Name: obj.Name, + Label: obj.Label, + LabelPlural: obj.LabelPlural, + KeyPrefix: obj.KeyPrefix, + Custom: obj.Custom, + Queryable: obj.Queryable, + }) + } + } + + if opts.Output == "json" { + return v.JSON(filtered) + } + + headers := []string{"Name", "Label", "Key Prefix", "Queryable"} + rows := make([][]string, 0, len(filtered)) + for _, obj := range filtered { + queryable := "No" + if obj.Queryable { + queryable = "Yes" + } + rows = append(rows, []string{obj.Name, obj.Label, obj.KeyPrefix, queryable}) + } + + if len(rows) == 0 { + v.Info("No custom objects found") + return nil + } + + return v.Table(headers, rows) + } + + if opts.Output == "json" { + return v.JSON(objects) + } + + headers := []string{"Name", "Label", "Key Prefix", "Custom", "Queryable"} + rows := make([][]string, 0, len(objects)) + for _, obj := range objects { + custom := "No" + if obj.Custom { + custom = "Yes" + } + queryable := "No" + if obj.Queryable { + queryable = "Yes" + } + rows = append(rows, []string{obj.Name, obj.Label, obj.KeyPrefix, custom, queryable}) + } + + if err := v.Table(headers, rows); err != nil { + return err + } + + v.Info("\n%d object(s)", len(objects)) + return nil +} diff --git a/internal/cmd/objectcmd/object.go b/internal/cmd/objectcmd/object.go new file mode 100644 index 0000000..b982da6 --- /dev/null +++ b/internal/cmd/objectcmd/object.go @@ -0,0 +1,28 @@ +// Package objectcmd provides commands for working with Salesforce objects. +package objectcmd + +import ( + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +// Register registers the object command with the root command. +func Register(parent *cobra.Command, opts *root.Options) { + parent.AddCommand(NewCommand(opts)) +} + +// NewCommand creates the object command with subcommands. +func NewCommand(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "object", + Short: "Work with Salesforce objects", + Long: "List, describe, and inspect Salesforce objects and their fields.", + } + + cmd.AddCommand(newListCommand(opts)) + cmd.AddCommand(newDescribeCommand(opts)) + cmd.AddCommand(newFieldsCommand(opts)) + + return cmd +} diff --git a/internal/cmd/objectcmd/object_test.go b/internal/cmd/objectcmd/object_test.go new file mode 100644 index 0000000..1e9563e --- /dev/null +++ b/internal/cmd/objectcmd/object_test.go @@ -0,0 +1,240 @@ +package objectcmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/salesforce-cli/api" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func TestListCommand(t *testing.T) { + sobjectsResp := api.SObjectsResponse{ + Encoding: "UTF-8", + MaxBatchSize: 200, + SObjects: []api.SObjectDescribe{ + {Name: "Account", Label: "Account", LabelPlural: "Accounts", KeyPrefix: "001", Queryable: true}, + {Name: "MyCustom__c", Label: "My Custom", LabelPlural: "My Customs", KeyPrefix: "a00", Custom: true, Queryable: true}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(sobjectsResp) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + t.Run("list all objects", func(t *testing.T) { + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newListCommand(opts) + cmd.SetOut(stdout) + err := cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Account") + assert.Contains(t, output, "MyCustom__c") + }) + + t.Run("list custom only", func(t *testing.T) { + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newListCommand(opts) + cmd.SetArgs([]string{"--custom-only"}) + cmd.SetOut(stdout) + err := cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.NotContains(t, output, "Account") + assert.Contains(t, output, "MyCustom__c") + }) +} + +func TestDescribeCommand(t *testing.T) { + describe := api.SObjectDescribe{ + Name: "Account", + Label: "Account", + LabelPlural: "Accounts", + KeyPrefix: "001", + Createable: true, + Updateable: true, + Deletable: true, + Queryable: true, + Searchable: true, + Fields: []api.Field{ + {Name: "Id", Label: "Account ID", Type: "id"}, + {Name: "Name", Label: "Account Name", Type: "string", Length: 255}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/sobjects/Account/describe") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(describe) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newDescribeCommand(opts) + cmd.SetArgs([]string{"Account"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Account") + assert.Contains(t, output, "Createable: true") + assert.Contains(t, output, "Fields: 2") +} + +func TestFieldsCommand(t *testing.T) { + describe := api.SObjectDescribe{ + Name: "Account", + Fields: []api.Field{ + {Name: "Id", Label: "Account ID", Type: "id", Nillable: false, Createable: false}, + {Name: "Name", Label: "Account Name", Type: "string", Length: 255, Nillable: false, Createable: true}, + {Name: "Description", Label: "Description", Type: "textarea", Nillable: true, Createable: true}, + {Name: "CustomField__c", Label: "Custom Field", Type: "string", Custom: true, Nillable: true, Createable: true}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(describe) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + t.Run("list all fields", func(t *testing.T) { + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newFieldsCommand(opts) + cmd.SetArgs([]string{"Account"}) + cmd.SetOut(stdout) + + err := cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Id") + assert.Contains(t, output, "Name") + assert.Contains(t, output, "Description") + assert.Contains(t, output, "CustomField__c") + assert.Contains(t, output, "4 field") + }) + + t.Run("list required only", func(t *testing.T) { + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newFieldsCommand(opts) + cmd.SetArgs([]string{"Account", "--required-only"}) + cmd.SetOut(stdout) + + err := cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Name") + assert.NotContains(t, output, "Description") + assert.Contains(t, output, "1 field") + }) +} + +func TestFieldsCommand_JSONOutput(t *testing.T) { + describe := api.SObjectDescribe{ + Name: "Account", + Fields: []api.Field{ + {Name: "Id", Label: "Account ID", Type: "id"}, + {Name: "Name", Label: "Account Name", Type: "string", Length: 255}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(describe) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "json", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newFieldsCommand(opts) + cmd.SetArgs([]string{"Account"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + var fields []api.Field + err = json.Unmarshal(stdout.Bytes(), &fields) + require.NoError(t, err) + assert.Len(t, fields, 2) +} diff --git a/internal/cmd/querycmd/query.go b/internal/cmd/querycmd/query.go new file mode 100644 index 0000000..e562b32 --- /dev/null +++ b/internal/cmd/querycmd/query.go @@ -0,0 +1,202 @@ +// Package querycmd provides the query command for executing SOQL queries. +package querycmd + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "sort" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/api" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +// Register registers the query command with the root command. +func Register(parent *cobra.Command, opts *root.Options) { + parent.AddCommand(NewCommand(opts)) +} + +// NewCommand creates the query command. +func NewCommand(opts *root.Options) *cobra.Command { + var ( + all bool + noLimit bool + ) + + cmd := &cobra.Command{ + Use: "query ", + Short: "Execute a SOQL query", + Long: `Execute a SOQL query against Salesforce and display the results. + +Examples: + sfdc query "SELECT Id, Name FROM Account LIMIT 10" + sfdc query "SELECT Id, Name FROM Account" --all + sfdc query "SELECT Id, Name, Phone FROM Contact" -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runQuery(cmd.Context(), opts, args[0], all, noLimit) + }, + } + + cmd.Flags().BoolVar(&all, "all", false, "Include deleted and archived records (queryAll)") + cmd.Flags().BoolVar(&noLimit, "no-limit", false, "Fetch all pages of results (may be slow for large datasets)") + + return cmd +} + +func runQuery(ctx context.Context, opts *root.Options, soql string, all, noLimit bool) error { + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + var result *api.QueryResult + + if all { + // Use queryAll to include deleted/archived records + result, err = queryAllRecords(ctx, client, soql) + } else if noLimit { + // Fetch all pages + result, err = client.QueryAll(ctx, soql) + } else { + // Single page query + result, err = client.Query(ctx, soql) + } + + if err != nil { + return fmt.Errorf("query failed: %w", err) + } + + return renderQueryResult(opts, result) +} + +// queryAllRecords uses the queryAll endpoint to include deleted/archived records +func queryAllRecords(ctx context.Context, client *api.Client, soql string) (*api.QueryResult, error) { + // The queryAll endpoint is at /queryAll instead of /query + // We need to make a direct request since the client doesn't have this method + path := fmt.Sprintf("/queryAll?q=%s", url.QueryEscape(soql)) + + // Use the client's Get method with URL encoding handled + body, err := client.Get(ctx, path) + if err != nil { + return nil, err + } + + var result api.QueryResult + if err := parseJSON(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse query result: %w", err) + } + + return &result, nil +} + +func renderQueryResult(opts *root.Options, result *api.QueryResult) error { + v := opts.View() + + if len(result.Records) == 0 { + v.Info("No records found (totalSize: %d)", result.TotalSize) + return nil + } + + // For JSON output, render the full result + if opts.Output == "json" { + return v.JSON(result) + } + + // For table/plain output, extract field names from first record + headers := extractHeaders(result.Records) + rows := extractRows(result.Records, headers) + + // Add record count footer + if err := v.Table(headers, rows); err != nil { + return err + } + + // Show pagination info if not all records fetched + if !result.Done { + v.Info("\nShowing %d of %d records (use --no-limit to fetch all)", len(result.Records), result.TotalSize) + } else { + v.Info("\n%d record(s)", result.TotalSize) + } + + return nil +} + +// extractHeaders gets column headers from the first record +func extractHeaders(records []api.SObject) []string { + if len(records) == 0 { + return nil + } + + headers := []string{"Id"} + + // Get field names from first record and sort for consistency + first := records[0] + fieldNames := make([]string, 0, len(first.Fields)) + for name := range first.Fields { + if name != "Id" { // Id is handled separately + fieldNames = append(fieldNames, name) + } + } + sort.Strings(fieldNames) + + return append(headers, fieldNames...) +} + +// extractRows converts records to string rows for table output +func extractRows(records []api.SObject, headers []string) [][]string { + rows := make([][]string, 0, len(records)) + + for _, rec := range records { + row := make([]string, len(headers)) + for i, header := range headers { + if header == "Id" { + row[i] = rec.ID + } else { + row[i] = formatFieldValue(rec.Fields[header]) + } + } + rows = append(rows, row) + } + + return rows +} + +// formatFieldValue converts a field value to a string for display +func formatFieldValue(v interface{}) string { + if v == nil { + return "" + } + + switch val := v.(type) { + case string: + return val + case float64: + // Check if it's a whole number + if val == float64(int64(val)) { + return fmt.Sprintf("%.0f", val) + } + return fmt.Sprintf("%v", val) + case bool: + if val { + return "true" + } + return "false" + case map[string]interface{}: + // Nested object (e.g., relationship) + if name, ok := val["Name"].(string); ok { + return name + } + return "[object]" + default: + return fmt.Sprintf("%v", val) + } +} + +// parseJSON is a helper to parse JSON response +func parseJSON(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} diff --git a/internal/cmd/querycmd/query_test.go b/internal/cmd/querycmd/query_test.go new file mode 100644 index 0000000..55fdfca --- /dev/null +++ b/internal/cmd/querycmd/query_test.go @@ -0,0 +1,216 @@ +package querycmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/salesforce-cli/api" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func TestQueryCommand(t *testing.T) { + tests := []struct { + name string + args []string + serverResponse api.QueryResult + wantErr bool + wantContains []string + }{ + { + name: "simple query with results", + args: []string{"SELECT Id, Name FROM Account"}, + serverResponse: api.QueryResult{ + TotalSize: 2, + Done: true, + Records: []api.SObject{ + {ID: "001xx000001", Fields: map[string]interface{}{"Name": "Acme Corp"}}, + {ID: "001xx000002", Fields: map[string]interface{}{"Name": "Test Inc"}}, + }, + }, + wantContains: []string{"001xx000001", "Acme Corp", "001xx000002", "Test Inc"}, + }, + { + name: "query with no results", + args: []string{"SELECT Id FROM Account WHERE Name = 'NonExistent'"}, + serverResponse: api.QueryResult{ + TotalSize: 0, + Done: true, + Records: []api.SObject{}, + }, + wantContains: []string{"No records found"}, + }, + { + name: "query with pagination indicator", + args: []string{"SELECT Id FROM Account"}, + serverResponse: api.QueryResult{ + TotalSize: 1000, + Done: false, + NextRecordsURL: "/services/data/v62.0/query/01gxx0000000001-500", + Records: []api.SObject{ + {ID: "001xx000001", Fields: map[string]interface{}{}}, + }, + }, + wantContains: []string{"Showing 1 of 1000 records"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(tt.serverResponse) + require.NoError(t, err) + })) + defer server.Close() + + // Create client pointing to test server + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + // Set up options with test client + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + // Create and execute command + cmd := NewCommand(opts) + cmd.SetArgs(tt.args) + cmd.SetOut(stdout) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + output := stdout.String() + for _, want := range tt.wantContains { + assert.Contains(t, output, want, "output should contain %q", want) + } + }) + } +} + +func TestQueryCommand_JSONOutput(t *testing.T) { + serverResponse := api.QueryResult{ + TotalSize: 1, + Done: true, + Records: []api.SObject{ + {ID: "001xx000001", Fields: map[string]interface{}{"Name": "Test"}}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(serverResponse) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "json", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := NewCommand(opts) + cmd.SetArgs([]string{"SELECT Id, Name FROM Account"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + // Verify JSON output + var result api.QueryResult + err = json.Unmarshal(stdout.Bytes(), &result) + require.NoError(t, err) + assert.Equal(t, 1, result.TotalSize) + assert.Len(t, result.Records, 1) +} + +func TestQueryCommand_AllFlag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify it hits the queryAll endpoint + assert.Contains(t, r.URL.Path, "/queryAll") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(api.QueryResult{ + TotalSize: 1, + Done: true, + Records: []api.SObject{ + {ID: "001xx000001", Fields: map[string]interface{}{"IsDeleted": true}}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := NewCommand(opts) + cmd.SetArgs([]string{"SELECT Id FROM Account", "--all"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) +} + +func TestFormatFieldValue(t *testing.T) { + tests := []struct { + name string + value interface{} + want string + }{ + {"nil", nil, ""}, + {"string", "hello", "hello"}, + {"integer float", float64(42), "42"}, + {"decimal float", 3.14, "3.14"}, + {"true bool", true, "true"}, + {"false bool", false, "false"}, + {"nested object with Name", map[string]interface{}{"Name": "Related"}, "Related"}, + {"nested object without Name", map[string]interface{}{"Id": "123"}, "[object]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatFieldValue(tt.value) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cmd/recordcmd/create.go b/internal/cmd/recordcmd/create.go new file mode 100644 index 0000000..55ebf45 --- /dev/null +++ b/internal/cmd/recordcmd/create.go @@ -0,0 +1,108 @@ +package recordcmd + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func newCreateCommand(opts *root.Options) *cobra.Command { + var setFlags []string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new record", + Long: `Create a new Salesforce record. + +Examples: + sfdc record create Account --set Name="Acme Corp" + sfdc record create Contact --set FirstName=John --set LastName=Doe --set Email=john@example.com + sfdc record create Account --set Name="Test" -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + fields, err := parseSetFlags(setFlags) + if err != nil { + return err + } + if len(fields) == 0 { + return fmt.Errorf("at least one --set flag is required") + } + return runCreate(cmd.Context(), opts, args[0], fields) + }, + } + + cmd.Flags().StringArrayVar(&setFlags, "set", nil, "Set field value (format: Field=Value)") + + return cmd +} + +func runCreate(ctx context.Context, opts *root.Options, objectName string, fields map[string]interface{}) error { + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + result, err := client.CreateRecord(ctx, objectName, fields) + if err != nil { + return fmt.Errorf("failed to create record: %w", err) + } + + v := opts.View() + + if opts.Output == "json" { + return v.JSON(result) + } + + if result.Success { + v.Success("Created %s record: %s", objectName, result.ID) + v.Info("URL: %s", client.RecordURL(result.ID)) + } else { + v.Error("Failed to create record") + for _, e := range result.Errors { + v.Error(" %s: %s", e.StatusCode, e.Message) + } + } + + return nil +} + +// parseSetFlags parses --set flags into a map of field values +func parseSetFlags(flags []string) (map[string]interface{}, error) { + result := make(map[string]interface{}) + + for _, flag := range flags { + parts := strings.SplitN(flag, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid --set format: %q (expected Field=Value)", flag) + } + + fieldName := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove surrounding quotes if present + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + + // Try to parse as boolean + switch strings.ToLower(value) { + case "true": + result[fieldName] = true + case "false": + result[fieldName] = false + case "null", "": + result[fieldName] = nil + default: + result[fieldName] = value + } + } + + return result, nil +} diff --git a/internal/cmd/recordcmd/delete.go b/internal/cmd/recordcmd/delete.go new file mode 100644 index 0000000..75bb4e1 --- /dev/null +++ b/internal/cmd/recordcmd/delete.go @@ -0,0 +1,76 @@ +package recordcmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func newDeleteCommand(opts *root.Options) *cobra.Command { + var confirm bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a record", + Long: `Delete a Salesforce record. + +Examples: + sfdc record delete Account 001xx000003DGbYAAW --confirm + sfdc record delete Contact 003xx000001abcd`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(cmd.Context(), opts, args[0], args[1], confirm) + }, + } + + cmd.Flags().BoolVar(&confirm, "confirm", false, "Skip confirmation prompt") + + return cmd +} + +func runDelete(ctx context.Context, opts *root.Options, objectName, recordID string, confirm bool) error { + v := opts.View() + + // Prompt for confirmation if not confirmed + if !confirm { + fmt.Printf("Delete %s record %s? [y/N]: ", objectName, recordID) + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + v.Info("Cancelled") + return nil + } + } + + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + err = client.DeleteRecord(ctx, objectName, recordID) + if err != nil { + return fmt.Errorf("failed to delete record: %w", err) + } + + if opts.Output == "json" { + return v.JSON(map[string]interface{}{ + "success": true, + "id": recordID, + "object": objectName, + "deleted": true, + }) + } + + v.Success("Deleted %s record: %s", objectName, recordID) + return nil +} diff --git a/internal/cmd/recordcmd/get.go b/internal/cmd/recordcmd/get.go new file mode 100644 index 0000000..8d40a6d --- /dev/null +++ b/internal/cmd/recordcmd/get.go @@ -0,0 +1,112 @@ +package recordcmd + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func newGetCommand(opts *root.Options) *cobra.Command { + var fields string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a record by ID", + Long: `Retrieve a Salesforce record by its ID. + +Examples: + sfdc record get Account 001xx000003DGbYAAW + sfdc record get Contact 003xx000001abcd --fields Name,Email,Phone + sfdc record get Account 001xx000003DGbYAAW -o json`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + var fieldList []string + if fields != "" { + fieldList = strings.Split(fields, ",") + for i := range fieldList { + fieldList[i] = strings.TrimSpace(fieldList[i]) + } + } + return runGet(cmd.Context(), opts, args[0], args[1], fieldList) + }, + } + + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated list of fields to retrieve") + + return cmd +} + +func runGet(ctx context.Context, opts *root.Options, objectName, recordID string, fields []string) error { + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + record, err := client.GetRecord(ctx, objectName, recordID, fields) + if err != nil { + return fmt.Errorf("failed to get record: %w", err) + } + + v := opts.View() + + if opts.Output == "json" { + return v.JSON(record) + } + + // Display as key-value pairs + v.Info("Object: %s", record.Attributes.Type) + v.Info("ID: %s", record.ID) + v.Info("") + + // Sort field names for consistent output + fieldNames := make([]string, 0, len(record.Fields)) + for name := range record.Fields { + fieldNames = append(fieldNames, name) + } + sort.Strings(fieldNames) + + for _, name := range fieldNames { + value := formatFieldValue(record.Fields[name]) + v.Info("%s: %s", name, value) + } + + // Show record URL + v.Info("") + v.Info("URL: %s", client.RecordURL(record.ID)) + + return nil +} + +// formatFieldValue converts a field value to a string for display +func formatFieldValue(v interface{}) string { + if v == nil { + return "(null)" + } + + switch val := v.(type) { + case string: + return val + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%.0f", val) + } + return fmt.Sprintf("%v", val) + case bool: + if val { + return "true" + } + return "false" + case map[string]interface{}: + if name, ok := val["Name"].(string); ok { + return name + } + return "[object]" + default: + return fmt.Sprintf("%v", val) + } +} diff --git a/internal/cmd/recordcmd/record.go b/internal/cmd/recordcmd/record.go new file mode 100644 index 0000000..7de0b56 --- /dev/null +++ b/internal/cmd/recordcmd/record.go @@ -0,0 +1,29 @@ +// Package recordcmd provides commands for working with Salesforce records. +package recordcmd + +import ( + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +// Register registers the record command with the root command. +func Register(parent *cobra.Command, opts *root.Options) { + parent.AddCommand(NewCommand(opts)) +} + +// NewCommand creates the record command with subcommands. +func NewCommand(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "record", + Short: "Work with Salesforce records", + Long: "Get, create, update, and delete Salesforce records.", + } + + cmd.AddCommand(newGetCommand(opts)) + cmd.AddCommand(newCreateCommand(opts)) + cmd.AddCommand(newUpdateCommand(opts)) + cmd.AddCommand(newDeleteCommand(opts)) + + return cmd +} diff --git a/internal/cmd/recordcmd/record_test.go b/internal/cmd/recordcmd/record_test.go new file mode 100644 index 0000000..21a6863 --- /dev/null +++ b/internal/cmd/recordcmd/record_test.go @@ -0,0 +1,293 @@ +package recordcmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/salesforce-cli/api" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func TestGetCommand(t *testing.T) { + record := api.SObject{ + ID: "001xx000001", + Attributes: api.SObjectAttributes{ + Type: "Account", + URL: "/services/data/v62.0/sobjects/Account/001xx000001", + }, + Fields: map[string]interface{}{ + "Name": "Acme Corp", + "Industry": "Technology", + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/sobjects/Account/001xx000001") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(record) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newGetCommand(opts) + cmd.SetArgs([]string{"Account", "001xx000001"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Account") + assert.Contains(t, output, "001xx000001") + assert.Contains(t, output, "Acme Corp") + assert.Contains(t, output, "Technology") +} + +func TestGetCommand_WithFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify fields parameter is passed + assert.Contains(t, r.URL.RawQuery, "fields=Name,Phone") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(api.SObject{ + ID: "001xx000001", + Attributes: api.SObjectAttributes{Type: "Account"}, + Fields: map[string]interface{}{"Name": "Test", "Phone": "555-1234"}, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newGetCommand(opts) + cmd.SetArgs([]string{"Account", "001xx000001", "--fields", "Name,Phone"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) +} + +func TestCreateCommand(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/sobjects/Account") + + // Verify request body + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "Acme Corp", body["Name"]) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(api.RecordResult{ + ID: "001xx000001", + Success: true, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newCreateCommand(opts) + cmd.SetArgs([]string{"Account", "--set", "Name=Acme Corp"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Created") + assert.Contains(t, output, "001xx000001") +} + +func TestCreateCommand_NoFields(t *testing.T) { + opts := &root.Options{ + Output: "table", + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } + + cmd := newCreateCommand(opts) + cmd.SetArgs([]string{"Account"}) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one --set flag") +} + +func TestUpdateCommand(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Contains(t, r.URL.Path, "/sobjects/Account/001xx000001") + + // Verify request body + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "555-1234", body["Phone"]) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newUpdateCommand(opts) + cmd.SetArgs([]string{"Account", "001xx000001", "--set", "Phone=555-1234"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Updated") +} + +func TestDeleteCommand_WithConfirm(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Contains(t, r.URL.Path, "/sobjects/Account/001xx000001") + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newDeleteCommand(opts) + cmd.SetArgs([]string{"Account", "001xx000001", "--confirm"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Deleted") +} + +func TestParseSetFlags(t *testing.T) { + tests := []struct { + name string + flags []string + want map[string]interface{} + wantErr bool + }{ + { + name: "simple string", + flags: []string{"Name=Acme"}, + want: map[string]interface{}{"Name": "Acme"}, + }, + { + name: "quoted string", + flags: []string{`Name="Acme Corp"`}, + want: map[string]interface{}{"Name": "Acme Corp"}, + }, + { + name: "boolean true", + flags: []string{"IsActive=true"}, + want: map[string]interface{}{"IsActive": true}, + }, + { + name: "boolean false", + flags: []string{"IsActive=false"}, + want: map[string]interface{}{"IsActive": false}, + }, + { + name: "null value", + flags: []string{"Description=null"}, + want: map[string]interface{}{"Description": nil}, + }, + { + name: "multiple fields", + flags: []string{"Name=Test", "Phone=555-1234", "IsActive=true"}, + want: map[string]interface{}{ + "Name": "Test", + "Phone": "555-1234", + "IsActive": true, + }, + }, + { + name: "invalid format", + flags: []string{"InvalidNoEquals"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSetFlags(tt.flags) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cmd/recordcmd/update.go b/internal/cmd/recordcmd/update.go new file mode 100644 index 0000000..6aee93a --- /dev/null +++ b/internal/cmd/recordcmd/update.go @@ -0,0 +1,64 @@ +package recordcmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func newUpdateCommand(opts *root.Options) *cobra.Command { + var setFlags []string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an existing record", + Long: `Update an existing Salesforce record. + +Examples: + sfdc record update Account 001xx000003DGbYAAW --set Name="New Name" + sfdc record update Contact 003xx000001abcd --set Phone="555-1234" --set Email=new@example.com`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + fields, err := parseSetFlags(setFlags) + if err != nil { + return err + } + if len(fields) == 0 { + return fmt.Errorf("at least one --set flag is required") + } + return runUpdate(cmd.Context(), opts, args[0], args[1], fields) + }, + } + + cmd.Flags().StringArrayVar(&setFlags, "set", nil, "Set field value (format: Field=Value)") + + return cmd +} + +func runUpdate(ctx context.Context, opts *root.Options, objectName, recordID string, fields map[string]interface{}) error { + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + err = client.UpdateRecord(ctx, objectName, recordID, fields) + if err != nil { + return fmt.Errorf("failed to update record: %w", err) + } + + v := opts.View() + + if opts.Output == "json" { + return v.JSON(map[string]interface{}{ + "success": true, + "id": recordID, + "object": objectName, + }) + } + + v.Success("Updated %s record: %s", objectName, recordID) + return nil +} diff --git a/internal/cmd/searchcmd/search.go b/internal/cmd/searchcmd/search.go new file mode 100644 index 0000000..4978371 --- /dev/null +++ b/internal/cmd/searchcmd/search.go @@ -0,0 +1,140 @@ +// Package searchcmd provides the search command for SOSL searches. +package searchcmd + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/salesforce-cli/api" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +// Register registers the search command with the root command. +func Register(parent *cobra.Command, opts *root.Options) { + parent.AddCommand(NewCommand(opts)) +} + +// NewCommand creates the search command. +func NewCommand(opts *root.Options) *cobra.Command { + var ( + inObjects string + returning string + ) + + cmd := &cobra.Command{ + Use: "search ", + Short: "Search for records using SOSL", + Long: `Search for records across multiple objects using Salesforce Object Search Language (SOSL). + +Examples: + sfdc search "Acme" + sfdc search "John Smith" --in Account,Contact + sfdc search "test" --returning "Account(Id,Name),Contact(Id,FirstName,LastName)" + sfdc search "FIND {Acme} IN ALL FIELDS RETURNING Account(Id,Name)"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runSearch(cmd.Context(), opts, args[0], inObjects, returning) + }, + } + + cmd.Flags().StringVar(&inObjects, "in", "", "Limit search to specific objects (comma-separated)") + cmd.Flags().StringVar(&returning, "returning", "", "Specify return fields per object (e.g., Account(Id,Name),Contact(Id,Email))") + + return cmd +} + +func runSearch(ctx context.Context, opts *root.Options, query, inObjects, returning string) error { + client, err := opts.APIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + // Build SOSL query + sosl := buildSOSL(query, inObjects, returning) + + result, err := client.Search(ctx, sosl) + if err != nil { + return fmt.Errorf("search failed: %w", err) + } + + return renderSearchResult(opts, result) +} + +// buildSOSL constructs a SOSL query from the input parameters +func buildSOSL(query, inObjects, returning string) string { + // If query already starts with FIND, use it as-is + if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(query)), "FIND") { + return query + } + + // Build SOSL from simplified parameters + var sb strings.Builder + sb.WriteString(fmt.Sprintf("FIND {%s}", query)) + + // Add IN clause if specified + if inObjects != "" { + sb.WriteString(" IN ALL FIELDS") + } + + // Add RETURNING clause + if returning != "" { + sb.WriteString(fmt.Sprintf(" RETURNING %s", returning)) + } else if inObjects != "" { + // Build RETURNING from --in objects + objects := strings.Split(inObjects, ",") + for i := range objects { + objects[i] = strings.TrimSpace(objects[i]) + } + sb.WriteString(fmt.Sprintf(" RETURNING %s", strings.Join(objects, ","))) + } + + return sb.String() +} + +func renderSearchResult(opts *root.Options, result *api.SearchResult) error { + v := opts.View() + + if len(result.SearchRecords) == 0 { + v.Info("No records found") + return nil + } + + if opts.Output == "json" { + return v.JSON(result) + } + + // Group results by object type + byType := make(map[string][]api.SearchRecord) + for _, rec := range result.SearchRecords { + objType := rec.Attributes.Type + byType[objType] = append(byType[objType], rec) + } + + // Display results grouped by type + for objType, records := range byType { + v.Info("%s (%d):", objType, len(records)) + + for _, rec := range records { + // Build display string from fields + fields := make([]string, 0) + for name, value := range rec.Fields { + if value != nil { + fields = append(fields, fmt.Sprintf("%s=%v", name, value)) + } + } + + if len(fields) > 0 { + v.Info(" %s: %s", rec.ID, strings.Join(fields, ", ")) + } else { + v.Info(" %s", rec.ID) + } + } + v.Info("") + } + + v.Info("%d record(s) found", len(result.SearchRecords)) + return nil +} diff --git a/internal/cmd/searchcmd/search_test.go b/internal/cmd/searchcmd/search_test.go new file mode 100644 index 0000000..05bfd07 --- /dev/null +++ b/internal/cmd/searchcmd/search_test.go @@ -0,0 +1,185 @@ +package searchcmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/salesforce-cli/api" + "github.com/open-cli-collective/salesforce-cli/internal/cmd/root" +) + +func TestSearchCommand(t *testing.T) { + searchResult := api.SearchResult{ + SearchRecords: []api.SearchRecord{ + { + Attributes: api.SObjectAttributes{Type: "Account"}, + ID: "001xx000001", + Fields: map[string]interface{}{"Name": "Acme Corp"}, + }, + { + Attributes: api.SObjectAttributes{Type: "Contact"}, + ID: "003xx000001", + Fields: map[string]interface{}{"Name": "John Doe"}, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/search") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(searchResult) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := NewCommand(opts) + cmd.SetArgs([]string{"Acme"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Account") + assert.Contains(t, output, "001xx000001") + assert.Contains(t, output, "Contact") + assert.Contains(t, output, "003xx000001") + assert.Contains(t, output, "2 record(s) found") +} + +func TestSearchCommand_NoResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(api.SearchResult{ + SearchRecords: []api.SearchRecord{}, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := NewCommand(opts) + cmd.SetArgs([]string{"nonexistent"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "No records found") +} + +func TestSearchCommand_JSONOutput(t *testing.T) { + searchResult := api.SearchResult{ + SearchRecords: []api.SearchRecord{ + { + Attributes: api.SObjectAttributes{Type: "Account"}, + ID: "001xx000001", + Fields: map[string]interface{}{"Name": "Test"}, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(searchResult) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "json", + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := NewCommand(opts) + cmd.SetArgs([]string{"Test"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + var result api.SearchResult + err = json.Unmarshal(stdout.Bytes(), &result) + require.NoError(t, err) + assert.Len(t, result.SearchRecords, 1) +} + +func TestBuildSOSL(t *testing.T) { + tests := []struct { + name string + query string + inObjects string + returning string + want string + }{ + { + name: "simple query", + query: "Acme", + want: "FIND {Acme}", + }, + { + name: "with in objects", + query: "Acme", + inObjects: "Account,Contact", + want: "FIND {Acme} IN ALL FIELDS RETURNING Account,Contact", + }, + { + name: "with returning", + query: "Acme", + returning: "Account(Id,Name),Contact(Id,Email)", + want: "FIND {Acme} RETURNING Account(Id,Name),Contact(Id,Email)", + }, + { + name: "raw SOSL passthrough", + query: "FIND {Acme} IN NAME FIELDS RETURNING Account(Id,Name)", + want: "FIND {Acme} IN NAME FIELDS RETURNING Account(Id,Name)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildSOSL(tt.query, tt.inObjects, tt.returning) + assert.Equal(t, tt.want, got) + }) + } +} From ab9deddabe6b7123d3351183832e3e4318fdd5b0 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 31 Jan 2026 19:25:25 -0500 Subject: [PATCH 2/2] test: add tests for delete confirmation prompt Use injectable opts.Stdin for testability. --- internal/cmd/recordcmd/delete.go | 5 +-- internal/cmd/recordcmd/record_test.go | 63 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/internal/cmd/recordcmd/delete.go b/internal/cmd/recordcmd/delete.go index 75bb4e1..642b07f 100644 --- a/internal/cmd/recordcmd/delete.go +++ b/internal/cmd/recordcmd/delete.go @@ -4,7 +4,6 @@ import ( "bufio" "context" "fmt" - "os" "strings" "github.com/spf13/cobra" @@ -39,8 +38,8 @@ func runDelete(ctx context.Context, opts *root.Options, objectName, recordID str // Prompt for confirmation if not confirmed if !confirm { - fmt.Printf("Delete %s record %s? [y/N]: ", objectName, recordID) - reader := bufio.NewReader(os.Stdin) + v.Print("Delete %s record %s? [y/N]: ", objectName, recordID) + reader := bufio.NewReader(opts.Stdin) response, err := reader.ReadString('\n') if err != nil { return fmt.Errorf("failed to read input: %w", err) diff --git a/internal/cmd/recordcmd/record_test.go b/internal/cmd/recordcmd/record_test.go index 21a6863..6419d98 100644 --- a/internal/cmd/recordcmd/record_test.go +++ b/internal/cmd/recordcmd/record_test.go @@ -198,6 +198,69 @@ func TestUpdateCommand(t *testing.T) { assert.Contains(t, output, "Updated") } +func TestDeleteCommand_PromptYes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + InstanceURL: server.URL, + HTTPClient: server.Client(), + }) + require.NoError(t, err) + + stdin := bytes.NewBufferString("y\n") + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdin: stdin, + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newDeleteCommand(opts) + cmd.SetArgs([]string{"Account", "001xx000001"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Deleted") +} + +func TestDeleteCommand_PromptNo(t *testing.T) { + // No server needed - should not make API call + client, err := api.New(api.ClientConfig{ + InstanceURL: "https://test.salesforce.com", + HTTPClient: &http.Client{}, + }) + require.NoError(t, err) + + stdin := bytes.NewBufferString("n\n") + stdout := &bytes.Buffer{} + opts := &root.Options{ + Output: "table", + Stdin: stdin, + Stdout: stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + cmd := newDeleteCommand(opts) + cmd.SetArgs([]string{"Account", "001xx000001"}) + cmd.SetOut(stdout) + + err = cmd.Execute() + require.NoError(t, err) + + output := stdout.String() + assert.Contains(t, output, "Cancelled") +} + func TestDeleteCommand_WithConfirm(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodDelete, r.Method)