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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ fizzy card unwatch 42
# Remove card header image
fizzy card image-remove 42

# Pin/unpin a card
fizzy card pin 42
fizzy card unpin 42

# Mark/unmark card as golden
fizzy card golden 42
fizzy card ungolden 42
Expand Down Expand Up @@ -349,6 +353,13 @@ fizzy user show USER_ID
fizzy tag list
```

### Pins

```bash
# List your pinned cards
fizzy pin list
```

### Search

```bash
Expand Down
13 changes: 7 additions & 6 deletions e2e/tests/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,6 @@ func TestAuthLogin(t *testing.T) {
}
defer os.RemoveAll(tmpDir)

configDir := filepath.Join(tmpDir, ".fizzy")

t.Run("saves token to config file", func(t *testing.T) {
// Run login with HOME set to temp directory
result := harness.Execute(cfg.BinaryPath, []string{"auth", "login", cfg.Token}, map[string]string{
Expand All @@ -122,10 +120,13 @@ func TestAuthLogin(t *testing.T) {
t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr)
}

// Check config file was created
configPath := filepath.Join(configDir, "config.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Error("config file was not created")
// Check config file was created (preferred path is ~/.config/fizzy/config.yaml)
preferredPath := filepath.Join(tmpDir, ".config", "fizzy", "config.yaml")
legacyPath := filepath.Join(tmpDir, ".fizzy", "config.yaml")
_, errPreferred := os.Stat(preferredPath)
_, errLegacy := os.Stat(legacyPath)
if os.IsNotExist(errPreferred) && os.IsNotExist(errLegacy) {
t.Errorf("config file was not created at either %s or %s", preferredPath, legacyPath)
}
})
}
Expand Down
6 changes: 4 additions & 2 deletions e2e/tests/card_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,8 +617,10 @@ func TestCardShowNotFound(t *testing.T) {
func TestCardCreateMissingBoard(t *testing.T) {
h := harness.New(t)

t.Run("fails without required --board option", func(t *testing.T) {
result := h.Run("card", "create", "--title", "Test")
t.Run("fails without required --board option when no default board configured", func(t *testing.T) {
// Use a temp HOME so the global config (which may have a default board) is not found
tmpHome := t.TempDir()
result := h.RunWithEnv(map[string]string{"HOME": tmpHome}, "card", "create", "--title", "Test")

// Should fail with error exit code
if result.ExitCode == harness.ExitSuccess {
Expand Down
6 changes: 4 additions & 2 deletions e2e/tests/column_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ func TestColumnList(t *testing.T) {
}
})

t.Run("fails without --board option", func(t *testing.T) {
result := h.Run("column", "list")
t.Run("fails without --board option when no default board configured", func(t *testing.T) {
// Use a temp HOME so the global config (which may have a default board) is not found
tmpHome := t.TempDir()
result := h.RunWithEnv(map[string]string{"HOME": tmpHome}, "column", "list")

if result.ExitCode == harness.ExitSuccess {
t.Error("expected non-zero exit code for missing required option")
Expand Down
6 changes: 2 additions & 4 deletions e2e/tests/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,8 @@ func TestSuccessResponseFormat(t *testing.T) {
t.Error("expected no error in success response")
}

// Pagination should be present for list operations
if result.Response.Pagination == nil {
t.Error("expected pagination in list response")
}
// Pagination may be absent when all results fit in a single page
// (the CLI only includes pagination when there is a next page)

// Meta should be present
if result.Response.Meta == nil {
Expand Down
168 changes: 168 additions & 0 deletions e2e/tests/pin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package tests

import (
"fmt"
"strconv"
"testing"
"time"

"github.com/robzolkos/fizzy-cli/e2e/harness"
)

func TestPinActions(t *testing.T) {
h := harness.New(t)
defer h.Cleanup.CleanupAll(h)

boardID := createTestBoard(t, h)

// Create a card for pin tests
title := fmt.Sprintf("Pin Test Card %d", time.Now().UnixNano())
result := h.Run("card", "create", "--board", boardID, "--title", title)
if result.ExitCode != harness.ExitSuccess {
t.Fatalf("failed to create test card: %s\nstdout: %s", result.Stderr, result.Stdout)
}
cardNumber := result.GetNumberFromLocation()
if cardNumber == 0 {
cardNumber = result.GetDataInt("number")
}
if cardNumber == 0 {
t.Fatalf("failed to get card number from create (location: %s)", result.GetLocation())
}
h.Cleanup.AddCard(cardNumber)
cardStr := strconv.Itoa(cardNumber)

t.Run("pin card", func(t *testing.T) {
result := h.Run("card", "pin", 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.Success {
t.Errorf("expected success=true, error: %+v", result.Response.Error)
}
})

t.Run("pin list includes pinned card", func(t *testing.T) {
result := h.Run("pin", "list")

if result.ExitCode != harness.ExitSuccess {
t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr)
}

if !result.Response.Success {
t.Errorf("expected success=true, error: %+v", result.Response.Error)
}

arr := result.GetDataArray()
if arr == nil {
t.Fatal("expected data to be an array")
}

// Find our pinned card in the list
found := false
for _, item := range arr {
card, ok := item.(map[string]interface{})
if !ok {
continue
}
if num, ok := card["number"].(float64); ok && int(num) == cardNumber {
found = true
break
}
}
if !found {
t.Errorf("expected pinned card #%d to appear in pin list", cardNumber)
}
})

t.Run("unpin card", func(t *testing.T) {
result := h.Run("card", "unpin", 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.Success {
t.Errorf("expected success=true, error: %+v", result.Response.Error)
}
})

t.Run("pin list excludes unpinned card", func(t *testing.T) {
result := h.Run("pin", "list")

if result.ExitCode != harness.ExitSuccess {
t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr)
}

arr := result.GetDataArray()
if arr == nil {
// Empty array is fine - card should not be there
return
}

for _, item := range arr {
card, ok := item.(map[string]interface{})
if !ok {
continue
}
if num, ok := card["number"].(float64); ok && int(num) == cardNumber {
t.Errorf("expected card #%d to NOT appear in pin list after unpinning", cardNumber)
}
}
})
}

func TestPinList(t *testing.T) {
h := harness.New(t)

t.Run("returns list of pinned cards", func(t *testing.T) {
result := h.Run("pin", "list")

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")
}

// Data should be an array
arr := result.GetDataArray()
if arr == nil {
t.Error("expected data to be an array")
}
})
}

func TestPinNotFound(t *testing.T) {
h := harness.New(t)

t.Run("pin non-existent card fails", func(t *testing.T) {
result := h.Run("card", "pin", "999999999")

if result.ExitCode == harness.ExitSuccess {
t.Error("expected non-zero exit code for non-existent card")
}

if result.Response != nil && result.Response.Success {
t.Error("expected success=false")
}
})

t.Run("unpin non-existent card fails", func(t *testing.T) {
result := h.Run("card", "unpin", "999999999")

if result.ExitCode == harness.ExitSuccess {
t.Error("expected non-zero exit code for non-existent card")
}

if result.Response != nil && result.Response.Success {
t.Error("expected success=false")
}
})
}
11 changes: 6 additions & 5 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,19 @@ func (c *Client) request(method, path string, body interface{}) (*APIResponse, e
LinkNext: parseLinkNext(resp.Header.Get("Link")),
}

// Check for error status codes before parsing JSON,
// since error responses may not be JSON (e.g. HTML 401 pages)
if resp.StatusCode >= 400 {
return apiResp, c.errorFromResponse(resp.StatusCode, respBody)
}

// Parse JSON body if present
if len(respBody) > 0 {
if err := json.Unmarshal(respBody, &apiResp.Data); err != nil {
return apiResp, errors.NewError(fmt.Sprintf("Failed to parse JSON response: %v", err))
}
}

// Check for error status codes
if resp.StatusCode >= 400 {
return apiResp, c.errorFromResponse(resp.StatusCode, respBody)
}

return apiResp, nil
}

Expand Down
70 changes: 70 additions & 0 deletions internal/commands/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,72 @@ var cardImageRemoveCmd = &cobra.Command{
},
}

var cardPinCmd = &cobra.Command{
Use: "pin CARD_NUMBER",
Short: "Pin a card",
Long: "Pins a card for quick access.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := requireAuthAndAccount(); err != nil {
exitWithError(err)
}

cardNumber := args[0]

client := getClient()
resp, err := client.Post("/cards/"+cardNumber+"/pin.json", nil)
if err != nil {
exitWithError(err)
}

// Build breadcrumbs
breadcrumbs := []response.Breadcrumb{
breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"),
breadcrumb("pins", "fizzy pin list", "List pinned cards"),
breadcrumb("unpin", fmt.Sprintf("fizzy card unpin %s", cardNumber), "Unpin card"),
}

data := resp.Data
if data == nil {
data = map[string]interface{}{}
}
printSuccessWithBreadcrumbs(data, "", breadcrumbs)
},
}

var cardUnpinCmd = &cobra.Command{
Use: "unpin CARD_NUMBER",
Short: "Unpin a card",
Long: "Unpins a card, removing it from your pinned list.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := requireAuthAndAccount(); err != nil {
exitWithError(err)
}

cardNumber := args[0]

client := getClient()
resp, err := client.Delete("/cards/" + cardNumber + "/pin.json")
if err != nil {
exitWithError(err)
}

// Build breadcrumbs
breadcrumbs := []response.Breadcrumb{
breadcrumb("show", fmt.Sprintf("fizzy card show %s", cardNumber), "View card"),
breadcrumb("pins", "fizzy pin list", "List pinned cards"),
breadcrumb("pin", fmt.Sprintf("fizzy card pin %s", cardNumber), "Pin card"),
}

data := resp.Data
if data == nil {
data = map[string]interface{}{}
}
printSuccessWithBreadcrumbs(data, "", breadcrumbs)
},
}

var cardGoldenCmd = &cobra.Command{
Use: "golden CARD_NUMBER",
Short: "Mark card as golden",
Expand Down Expand Up @@ -1029,4 +1095,8 @@ func init() {
// Golden
cardCmd.AddCommand(cardGoldenCmd)
cardCmd.AddCommand(cardUngoldenCmd)

// Pin/Unpin
cardCmd.AddCommand(cardPinCmd)
cardCmd.AddCommand(cardUnpinCmd)
}
Loading