diff --git a/README.md b/README.md index 0333188..ef9f997 100644 --- a/README.md +++ b/README.md @@ -317,13 +317,22 @@ fizzy step delete STEP_ID --card 42 ### Reactions ```bash +# List reactions on a card +fizzy reaction list --card 42 + # List reactions on a comment fizzy reaction list --card 42 --comment COMMENT_ID -# Add a reaction (emoji, max 16 chars) +# Add a reaction to a card (max 16 chars) +fizzy reaction create --card 42 --content "👍" + +# Add a reaction to a comment (max 16 chars) fizzy reaction create --card 42 --comment COMMENT_ID --content "👍" -# Remove a reaction +# Remove a reaction from a card +fizzy reaction delete REACTION_ID --card 42 + +# Remove a reaction from a comment fizzy reaction delete REACTION_ID --card 42 --comment COMMENT_ID ``` diff --git a/e2e/harness/cleanup.go b/e2e/harness/cleanup.go index 9eeb063..da232d7 100644 --- a/e2e/harness/cleanup.go +++ b/e2e/harness/cleanup.go @@ -88,23 +88,36 @@ func (c *CleanupTracker) AddStep(id string, cardNumber int) { c.Steps = append(c.Steps, StepRef{ID: id, CardNumber: cardNumber}) } -// AddReaction adds a reaction to the cleanup list. +// AddReaction adds a comment reaction to the cleanup list. func (c *CleanupTracker) AddReaction(id string, cardNumber int, commentID string) { c.Reactions = append(c.Reactions, ReactionRef{ID: id, CardNumber: cardNumber, CommentID: commentID}) } +// AddCardReaction adds a card reaction to the cleanup list. +func (c *CleanupTracker) AddCardReaction(id string, cardNumber int) { + c.Reactions = append(c.Reactions, ReactionRef{ID: id, CardNumber: cardNumber, CommentID: ""}) +} + // CleanupAll deletes all tracked resources in reverse dependency order. // It uses the provided harness to execute delete commands. func (c *CleanupTracker) CleanupAll(h *Harness) []error { var errors []error // Delete in reverse dependency order: - // 1. Reactions (depend on comments) + // 1. Reactions (depend on comments or cards) for i := len(c.Reactions) - 1; i >= 0; i-- { ref := c.Reactions[i] - result := h.Run("reaction", "delete", ref.ID, - "--card", strconv.Itoa(ref.CardNumber), - "--comment", ref.CommentID) + var result *Result + if ref.CommentID != "" { + // Comment reaction + result = h.Run("reaction", "delete", ref.ID, + "--card", strconv.Itoa(ref.CardNumber), + "--comment", ref.CommentID) + } else { + // Card reaction + result = h.Run("reaction", "delete", ref.ID, + "--card", strconv.Itoa(ref.CardNumber)) + } if result.ExitCode != 0 && result.ExitCode != ExitNotFound { errors = append(errors, fmt.Errorf("failed to delete reaction %s: exit %d", ref.ID, result.ExitCode)) } diff --git a/e2e/tests/reaction_test.go b/e2e/tests/reaction_test.go index 19a29b1..fc372e6 100644 --- a/e2e/tests/reaction_test.go +++ b/e2e/tests/reaction_test.go @@ -69,9 +69,41 @@ func TestReactionList(t *testing.T) { } }) - t.Run("fails without --comment option", func(t *testing.T) { +} + +// TestCardReactionList tests listing reactions directly on a card (no --comment flag) +func TestCardReactionList(t *testing.T) { + h := harness.New(t) + defer h.Cleanup.CleanupAll(h) + + boardID := createTestBoard(t, h) + cardNumber := createTestCard(t, h, boardID) + cardStr := strconv.Itoa(cardNumber) + + t.Run("returns list of reactions for card", func(t *testing.T) { result := h.Run("reaction", "list", "--card", cardStr) + if result.ExitCode != harness.ExitSuccess { + t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) + } + + if result.Response == nil { + t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) + } + + if !result.Response.Success { + t.Error("expected success=true") + } + + arr := result.GetDataArray() + if arr == nil { + t.Error("expected data to be an array") + } + }) + + t.Run("fails without --card option", func(t *testing.T) { + result := h.Run("reaction", "list") + if result.ExitCode == harness.ExitSuccess { t.Error("expected non-zero exit code for missing required option") } @@ -162,11 +194,93 @@ func TestReactionCreateMissingContent(t *testing.T) { commentID := createTestComment(t, h, cardNumber) cardStr := strconv.Itoa(cardNumber) - t.Run("fails without --content option", func(t *testing.T) { + t.Run("fails without --content option for comment reaction", func(t *testing.T) { result := h.Run("reaction", "create", "--card", cardStr, "--comment", commentID) if result.ExitCode == harness.ExitSuccess { t.Error("expected non-zero exit code for missing required option") } }) + + t.Run("fails without --content option for card reaction", func(t *testing.T) { + result := h.Run("reaction", "create", "--card", cardStr) + + if result.ExitCode == harness.ExitSuccess { + t.Error("expected non-zero exit code for missing required option") + } + }) +} + +// TestCardReactionCRUD tests creating and deleting reactions directly on cards +func TestCardReactionCRUD(t *testing.T) { + h := harness.New(t) + defer h.Cleanup.CleanupAll(h) + + boardID := createTestBoard(t, h) + cardNumber := createTestCard(t, h, boardID) + cardStr := strconv.Itoa(cardNumber) + + var reactionID string + + t.Run("create card reaction", func(t *testing.T) { + result := h.Run("reaction", "create", "--card", cardStr, "--content", "🎉") + + if result.ExitCode != harness.ExitSuccess { + t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s", + harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout) + } + + if result.Response == nil { + t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout) + } + + if !result.Response.Success { + t.Errorf("expected success=true, error: %+v", result.Response.Error) + } + + // List reactions to get the ID for cleanup/deletion + listResult := h.Run("reaction", "list", "--card", cardStr) + if listResult.ExitCode == harness.ExitSuccess && listResult.Response != nil { + arr := listResult.GetDataArray() + if len(arr) > 0 { + // Get the last reaction (most recently created) + lastReaction := arr[len(arr)-1].(map[string]interface{}) + if id, ok := lastReaction["id"].(string); ok { + reactionID = id + h.Cleanup.AddCardReaction(reactionID, cardNumber) + } + } + } + if reactionID == "" { + t.Log("Warning: could not get reaction ID for cleanup") + } + }) + + t.Run("delete card reaction", func(t *testing.T) { + if reactionID == "" { + t.Skip("no reaction ID from create test") + } + + result := h.Run("reaction", "delete", reactionID, "--card", cardStr) + + if result.ExitCode != harness.ExitSuccess { + t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr) + } + + if result.Response == nil { + t.Fatal("expected JSON response") + } + + if !result.Response.Success { + t.Error("expected success=true") + } + + deleted := result.GetDataBool("deleted") + if !deleted { + t.Error("expected deleted=true") + } + + // Remove from cleanup since we deleted it + h.Cleanup.Reactions = nil + }) } diff --git a/internal/commands/reaction.go b/internal/commands/reaction.go index 64ed904..9f6fd66 100644 --- a/internal/commands/reaction.go +++ b/internal/commands/reaction.go @@ -9,7 +9,7 @@ import ( var reactionCmd = &cobra.Command{ Use: "reaction", Short: "Manage reactions", - Long: "Commands for managing comment reactions.", + Long: "Commands for managing reactions on cards and comments.", } // Reaction list flags @@ -18,8 +18,8 @@ var reactionListComment string var reactionListCmd = &cobra.Command{ Use: "list", - Short: "List reactions for a comment", - Long: "Lists all reactions for a specific comment.", + Short: "List reactions", + Long: "Lists reactions on a card, or on a comment if --comment is provided.", Run: func(cmd *cobra.Command, args []string) { if err := requireAuthAndAccount(); err != nil { exitWithError(err) @@ -28,12 +28,17 @@ var reactionListCmd = &cobra.Command{ if reactionListCard == "" { exitWithError(newRequiredFlagError("card")) } - if reactionListComment == "" { - exitWithError(newRequiredFlagError("comment")) + + // Build URL based on whether --comment was provided + var path string + if reactionListComment != "" { + path = "/cards/" + reactionListCard + "/comments/" + reactionListComment + "/reactions.json" + } else { + path = "/cards/" + reactionListCard + "/reactions.json" } client := getClient() - resp, err := client.Get("/cards/" + reactionListCard + "/comments/" + reactionListComment + "/reactions.json") + resp, err := client.Get(path) if err != nil { exitWithError(err) } @@ -43,7 +48,12 @@ var reactionListCmd = &cobra.Command{ if arr, ok := resp.Data.([]interface{}); ok { count = len(arr) } - summary := fmt.Sprintf("%d reactions on comment", count) + var summary string + if reactionListComment != "" { + summary = fmt.Sprintf("%d reactions on comment", count) + } else { + summary = fmt.Sprintf("%d reactions on card #%s", count, reactionListCard) + } printSuccessWithSummary(resp.Data, summary) }, @@ -56,8 +66,8 @@ var reactionCreateContent string var reactionCreateCmd = &cobra.Command{ Use: "create", - Short: "Add a reaction to a comment", - Long: "Adds an emoji reaction to a comment.", + Short: "Add a reaction", + Long: "Adds a reaction to a card, or to a comment if --comment is provided.", Run: func(cmd *cobra.Command, args []string) { if err := requireAuthAndAccount(); err != nil { exitWithError(err) @@ -66,9 +76,6 @@ var reactionCreateCmd = &cobra.Command{ if reactionCreateCard == "" { exitWithError(newRequiredFlagError("card")) } - if reactionCreateComment == "" { - exitWithError(newRequiredFlagError("comment")) - } if reactionCreateContent == "" { exitWithError(newRequiredFlagError("content")) } @@ -77,8 +84,16 @@ var reactionCreateCmd = &cobra.Command{ "content": reactionCreateContent, } + // Build URL based on whether --comment was provided + var path string + if reactionCreateComment != "" { + path = "/cards/" + reactionCreateCard + "/comments/" + reactionCreateComment + "/reactions.json" + } else { + path = "/cards/" + reactionCreateCard + "/reactions.json" + } + client := getClient() - resp, err := client.Post("/cards/"+reactionCreateCard+"/comments/"+reactionCreateComment+"/reactions.json", body) + resp, err := client.Post(path, body) if err != nil { exitWithError(err) } @@ -99,7 +114,7 @@ var reactionDeleteComment string var reactionDeleteCmd = &cobra.Command{ Use: "delete REACTION_ID", Short: "Remove a reaction", - Long: "Removes a reaction from a comment.", + Long: "Removes a reaction from a card, or from a comment if --comment is provided.", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { if err := requireAuthAndAccount(); err != nil { @@ -109,12 +124,17 @@ var reactionDeleteCmd = &cobra.Command{ if reactionDeleteCard == "" { exitWithError(newRequiredFlagError("card")) } - if reactionDeleteComment == "" { - exitWithError(newRequiredFlagError("comment")) + + // Build URL based on whether --comment was provided + var path string + if reactionDeleteComment != "" { + path = "/cards/" + reactionDeleteCard + "/comments/" + reactionDeleteComment + "/reactions/" + args[0] + ".json" + } else { + path = "/cards/" + reactionDeleteCard + "/reactions/" + args[0] + ".json" } client := getClient() - _, err := client.Delete("/cards/" + reactionDeleteCard + "/comments/" + reactionDeleteComment + "/reactions/" + args[0] + ".json") + _, err := client.Delete(path) if err != nil { exitWithError(err) } @@ -130,17 +150,17 @@ func init() { // List reactionListCmd.Flags().StringVar(&reactionListCard, "card", "", "Card number (required)") - reactionListCmd.Flags().StringVar(&reactionListComment, "comment", "", "Comment ID (required)") + reactionListCmd.Flags().StringVar(&reactionListComment, "comment", "", "Comment ID (optional, for comment reactions)") reactionCmd.AddCommand(reactionListCmd) // Create reactionCreateCmd.Flags().StringVar(&reactionCreateCard, "card", "", "Card number (required)") - reactionCreateCmd.Flags().StringVar(&reactionCreateComment, "comment", "", "Comment ID (required)") - reactionCreateCmd.Flags().StringVar(&reactionCreateContent, "content", "", "Emoji content (required)") + reactionCreateCmd.Flags().StringVar(&reactionCreateComment, "comment", "", "Comment ID (optional, for comment reactions)") + reactionCreateCmd.Flags().StringVar(&reactionCreateContent, "content", "", "Reaction content (required)") reactionCmd.AddCommand(reactionCreateCmd) // Delete reactionDeleteCmd.Flags().StringVar(&reactionDeleteCard, "card", "", "Card number (required)") - reactionDeleteCmd.Flags().StringVar(&reactionDeleteComment, "comment", "", "Comment ID (required)") + reactionDeleteCmd.Flags().StringVar(&reactionDeleteComment, "comment", "", "Comment ID (optional, for comment reactions)") reactionCmd.AddCommand(reactionDeleteCmd) } diff --git a/internal/commands/reaction_test.go b/internal/commands/reaction_test.go index a9024b6..1afd453 100644 --- a/internal/commands/reaction_test.go +++ b/internal/commands/reaction_test.go @@ -55,8 +55,13 @@ func TestReactionList(t *testing.T) { } }) - t.Run("requires comment flag", func(t *testing.T) { + t.Run("lists card reactions without comment flag", func(t *testing.T) { mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: []interface{}{}, + } + result := SetTestMode(mock) SetTestConfig("token", "account", "https://api.example.com") defer ResetTestMode() @@ -68,14 +73,17 @@ func TestReactionList(t *testing.T) { }) reactionListCard = "" - if result.ExitCode != errors.ExitInvalidArgs { - t.Errorf("expected exit code %d, got %d", errors.ExitInvalidArgs, result.ExitCode) + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + if mock.GetCalls[0].Path != "/cards/42/reactions.json" { + t.Errorf("expected path '/cards/42/reactions.json', got '%s'", mock.GetCalls[0].Path) } }) } func TestReactionCreate(t *testing.T) { - t.Run("creates reaction", func(t *testing.T) { + t.Run("creates comment reaction", func(t *testing.T) { mock := NewMockClient() mock.PostResponse = &client.APIResponse{ StatusCode: 201, @@ -108,10 +116,43 @@ func TestReactionCreate(t *testing.T) { t.Errorf("expected content '👍', got '%v'", body["content"]) } }) + + t.Run("creates card reaction without comment flag", func(t *testing.T) { + mock := NewMockClient() + mock.PostResponse = &client.APIResponse{ + StatusCode: 201, + Data: map[string]interface{}{}, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + reactionCreateCard = "42" + reactionCreateComment = "" + reactionCreateContent = "🎉" + RunTestCommand(func() { + reactionCreateCmd.Run(reactionCreateCmd, []string{}) + }) + reactionCreateCard = "" + reactionCreateContent = "" + + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + if mock.PostCalls[0].Path != "/cards/42/reactions.json" { + t.Errorf("expected path '/cards/42/reactions.json', got '%s'", mock.PostCalls[0].Path) + } + + body := mock.PostCalls[0].Body.(map[string]interface{}) + if body["content"] != "🎉" { + t.Errorf("expected content '🎉', got '%v'", body["content"]) + } + }) } func TestReactionDelete(t *testing.T) { - t.Run("deletes reaction", func(t *testing.T) { + t.Run("deletes comment reaction", func(t *testing.T) { mock := NewMockClient() mock.DeleteResponse = &client.APIResponse{ StatusCode: 204, @@ -137,4 +178,30 @@ func TestReactionDelete(t *testing.T) { t.Errorf("expected path '/cards/42/comments/comment-1/reactions/reaction-1.json', got '%s'", mock.DeleteCalls[0].Path) } }) + + t.Run("deletes card reaction without comment flag", func(t *testing.T) { + mock := NewMockClient() + mock.DeleteResponse = &client.APIResponse{ + StatusCode: 204, + Data: map[string]interface{}{}, + } + + result := SetTestMode(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer ResetTestMode() + + reactionDeleteCard = "42" + reactionDeleteComment = "" + RunTestCommand(func() { + reactionDeleteCmd.Run(reactionDeleteCmd, []string{"reaction-1"}) + }) + reactionDeleteCard = "" + + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + if mock.DeleteCalls[0].Path != "/cards/42/reactions/reaction-1.json" { + t.Errorf("expected path '/cards/42/reactions/reaction-1.json', got '%s'", mock.DeleteCalls[0].Path) + } + }) }