Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions internal/commands/todolistgroups.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ func newTodolistgroupsPositionCmd() *cobra.Command {
Short: "Change group position",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if position == 0 {
return output.ErrUsage("--position is required (1-based)")
}

app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
Expand All @@ -358,10 +362,6 @@ func newTodolistgroupsPositionCmd() *cobra.Command {

groupIDStr := args[0]

if position == 0 {
return output.ErrUsage("--position is required (1-based)")
}

groupID, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
return output.ErrUsage("Invalid group ID")
Expand All @@ -381,7 +381,6 @@ func newTodolistgroupsPositionCmd() *cobra.Command {

cmd.Flags().IntVar(&position, "position", 0, "New position, 1-based (required)")
cmd.Flags().IntVar(&position, "pos", 0, "New position (alias)")
_ = cmd.MarkFlagRequired("position")

return cmd
}
45 changes: 45 additions & 0 deletions internal/commands/todolistgroups_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package commands

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/basecamp-cli/internal/output"
)

// TestTodolistgroupsPositionAcceptsPosAlias tests that --pos works as an alias for --position.
func TestTodolistgroupsPositionAcceptsPosAlias(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

cmd := newTodolistgroupsPositionCmd()

// --pos should reach the RunE and proceed past the position guard
err := executeCommand(cmd, app, "456", "--pos", "2")

// Expect an API/network error — NOT "required flag" and NOT the RunE usage guard
require.NotNil(t, err)
assert.NotContains(t, err.Error(), "required flag")
var e *output.Error
if errors.As(err, &e) {
assert.NotEqual(t, "--position is required (1-based)", e.Message)
}
}

// TestTodolistgroupsPositionRequiresPosition tests the RunE guard when neither flag is given.
func TestTodolistgroupsPositionRequiresPosition(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

cmd := newTodolistgroupsPositionCmd()

err := executeCommand(cmd, app, "456")
require.NotNil(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Equal(t, "--position is required (1-based)", e.Message)
}
18 changes: 8 additions & 10 deletions internal/commands/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,16 @@ func newToolsCreateCmd(project *string) *cobra.Command {

For example, clone a Campfire to create a second chat room in the same project.`,
RunE: func(cmd *cobra.Command, args []string) error {
if sourceID == "" {
return output.ErrUsage("--source or --clone is required (ID of tool to clone)")
}

app := appctx.FromContext(cmd.Context())

if err := ensureAccount(cmd, app); err != nil {
return err
}

if sourceID == "" {
return output.ErrUsage("--source is required (ID of tool to clone)")
}

sourceToolID, err := strconv.ParseInt(sourceID, 10, 64)
if err != nil {
return output.ErrUsage("Invalid source tool ID")
Expand Down Expand Up @@ -203,7 +203,6 @@ For example, clone a Campfire to create a second chat room in the same project.`

cmd.Flags().StringVarP(&sourceID, "source", "s", "", "Source tool ID to clone (required)")
cmd.Flags().StringVar(&sourceID, "clone", "", "Source tool ID (alias for --source)")
_ = cmd.MarkFlagRequired("source")

return cmd
}
Expand Down Expand Up @@ -470,6 +469,10 @@ func newToolsRepositionCmd(project *string) *cobra.Command {
Long: "Move a tool to a different position in the project dock.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if position == 0 {
return output.ErrUsage("--position is required (1-based)")
}

app := appctx.FromContext(cmd.Context())

if err := ensureAccount(cmd, app); err != nil {
Expand All @@ -481,10 +484,6 @@ func newToolsRepositionCmd(project *string) *cobra.Command {
return output.ErrUsage("Invalid tool ID")
}

if position == 0 {
return output.ErrUsage("--position is required (1-based)")
}

// Resolve project, with interactive fallback
projectID := *project
if projectID == "" {
Expand Down Expand Up @@ -525,7 +524,6 @@ func newToolsRepositionCmd(project *string) *cobra.Command {

cmd.Flags().IntVar(&position, "position", 0, "New position, 1-based (required)")
cmd.Flags().IntVar(&position, "pos", 0, "New position (alias)")
_ = cmd.MarkFlagRequired("position")

return cmd
}
85 changes: 85 additions & 0 deletions internal/commands/tools_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package commands

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/basecamp-cli/internal/output"
)

// TestToolsCreateAcceptsCloneAlias tests that --clone works as an alias for --source.
// Previously, MarkFlagRequired("source") caused Cobra to reject --clone before RunE ran.
func TestToolsCreateAcceptsCloneAlias(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cmd := newToolsCreateCmd(&project)

// --clone should reach the RunE guard, not fail with "required flag not set"
err := executeCommand(cmd, app, "--clone", "999", "My Tool")

// Expect an API/network error — NOT "required flag" and NOT the RunE usage guard
require.NotNil(t, err)
var e *output.Error
if errors.As(err, &e) {
assert.NotEqual(t, "--source or --clone is required (ID of tool to clone)", e.Message)
}
assert.NotContains(t, err.Error(), "required flag")
}

// TestToolsCreateRequiresSourceOrClone tests that omitting both --source and --clone
// produces a usage error from the RunE guard.
func TestToolsCreateRequiresSourceOrClone(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cmd := newToolsCreateCmd(&project)

err := executeCommand(cmd, app, "My Tool")
require.NotNil(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Equal(t, "--source or --clone is required (ID of tool to clone)", e.Message)
}

// TestToolsRepositionAcceptsPosAlias tests that --pos works as an alias for --position.
func TestToolsRepositionAcceptsPosAlias(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cmd := newToolsRepositionCmd(&project)

// --pos should reach the RunE and proceed past the position guard
err := executeCommand(cmd, app, "456", "--pos", "2")

// Expect an API/network error — NOT "required flag" and NOT the RunE usage guard
require.NotNil(t, err)
assert.NotContains(t, err.Error(), "required flag")
var e *output.Error
if errors.As(err, &e) {
assert.NotEqual(t, "--position is required (1-based)", e.Message)
}
}

// TestToolsRepositionRequiresPosition tests the RunE guard when neither flag is given.
func TestToolsRepositionRequiresPosition(t *testing.T) {
app, _ := setupTestApp(t)
app.Config.ProjectID = "123"

project := ""
cmd := newToolsRepositionCmd(&project)

err := executeCommand(cmd, app, "456")
require.NotNil(t, err)

var e *output.Error
require.True(t, errors.As(err, &e))
assert.Equal(t, "--position is required (1-based)", e.Message)
}
Loading