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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ sure-cli family-exports download <export_id> --out sure-export.zip

# Reference data and rules
sure-cli categories list --roots-only
sure-cli categories show <category_id>
sure-cli categories create --name Food --color '#3b82f6'
sure-cli categories create --name Food --color '#3b82f6' --icon utensils --parent-id <parent_id> --apply
sure-cli merchants list
sure-cli tags create --name Travel --color '#3b82f6'
sure-cli tags create --name Travel --color '#3b82f6' --apply
Expand Down
110 changes: 110 additions & 0 deletions cmd/sure-cli/root/categories_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package root

import "testing"

func TestBuildCategoryCreatePayload_RequiresName(t *testing.T) {
if _, err := buildCategoryCreatePayload(categoryCreateOpts{Color: "#3b82f6"}); err == nil {
t.Fatal("expected missing name to error")
}
}

func TestBuildCategoryCreatePayload_RequiresColor(t *testing.T) {
if _, err := buildCategoryCreatePayload(categoryCreateOpts{Name: "Food"}); err == nil {
t.Fatal("expected missing color to error (Category model validates color presence)")
}
}

func TestBuildCategoryCreatePayload_ColorMustBeHex(t *testing.T) {
// Upstream validates `format: { with: /\A#[0-9A-Fa-f]{6}\z/ }`. Catch the
// obvious format errors client-side so users get fast feedback.
cases := []string{"3b82f6", "blue", "#abc", "#GGGGGG", "#3b82f6 "}
for _, c := range cases {
if _, err := buildCategoryCreatePayload(categoryCreateOpts{Name: "Food", Color: c}); err == nil {
t.Fatalf("expected color %q to be rejected", c)
}
}
}

func TestBuildCategoryCreatePayload_AcceptsValidHex(t *testing.T) {
for _, c := range []string{"#3b82f6", "#000000", "#FFFFFF", "#abcdef"} {
if _, err := buildCategoryCreatePayload(categoryCreateOpts{Name: "Food", Color: c}); err != nil {
t.Fatalf("color %q should be accepted, got %v", c, err)
}
}
}

func TestBuildCategoryCreatePayload_WrapsInCategoryKey(t *testing.T) {
// Upstream uses `params.require(:category)` — body must be `{"category": {...}}`.
payload, err := buildCategoryCreatePayload(categoryCreateOpts{Name: "Food", Color: "#3b82f6"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cat, ok := payload["category"].(map[string]any)
if !ok {
t.Fatalf("payload['category'] not map: %#v", payload)
}
if cat["name"] != "Food" || cat["color"] != "#3b82f6" {
t.Fatalf("category = %#v", cat)
}
if _, has := cat["icon"]; has {
t.Fatal("icon should be omitted when empty (upstream auto-suggests)")
}
if _, has := cat["parent_id"]; has {
t.Fatal("parent_id should be omitted when empty")
}
}

func TestBuildCategoryCreatePayload_OptionalFields(t *testing.T) {
payload, err := buildCategoryCreatePayload(categoryCreateOpts{
Name: "Subscriptions",
Color: "#3b82f6",
Icon: "wallet",
ParentID: "parent-uuid",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cat := payload["category"].(map[string]any)
if cat["icon"] != "wallet" {
t.Fatalf("icon = %v", cat["icon"])
}
if cat["parent_id"] != "parent-uuid" {
t.Fatalf("parent_id = %v", cat["parent_id"])
}
}

func TestBuildCategoryCreatePayload_WhitespaceOnlyNameRejected(t *testing.T) {
if _, err := buildCategoryCreatePayload(categoryCreateOpts{Name: " ", Color: "#3b82f6"}); err == nil {
t.Fatal("expected whitespace-only name to be rejected")
}
}

func TestBuildCategoryCreatePayload_TrimsNameInPayload(t *testing.T) {
// Regression: trimming was applied for validation but the original value
// was sent in the payload, leaking whitespace into the upstream uniqueness
// check.
payload, err := buildCategoryCreatePayload(categoryCreateOpts{Name: " Food ", Color: "#3b82f6"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cat := payload["category"].(map[string]any)
if cat["name"] != "Food" {
t.Fatalf("name in payload not trimmed: %q", cat["name"])
}
}

func TestCategoriesCreateRegistered(t *testing.T) {
root := New()
got, _, err := root.Find([]string{"categories", "create"})
if err != nil {
t.Fatalf("categories create not registered: %v", err)
}
if got.Name() != "create" {
t.Fatalf("resolved to %q, want create", got.Name())
}
for _, f := range []string{"name", "color", "icon", "parent-id", "apply"} {
if got.Flags().Lookup(f) == nil {
t.Fatalf("categories create missing --%s", f)
}
}
}
72 changes: 72 additions & 0 deletions cmd/sure-cli/root/reference_cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package root
import (
"fmt"
"net/url"
"regexp"
"strings"

"github.com/spf13/cobra"
"github.com/we-promise/sure-cli/internal/api"
Expand Down Expand Up @@ -44,6 +46,76 @@ func newCategoriesCmd() *cobra.Command {
},
})

cmd.AddCommand(newCategoriesCreateCmd())

return cmd
}

type categoryCreateOpts struct {
Name string
Color string
Icon string
ParentID string
Apply bool
}

// categoryHexColorRE matches the same format the Category model enforces:
// `/\A#[0-9A-Fa-f]{6}\z/`. Validating client-side gives fast feedback before
// the upstream 422.
var categoryHexColorRE = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)

func buildCategoryCreatePayload(o categoryCreateOpts) (map[string]any, error) {
name := strings.TrimSpace(o.Name)
if name == "" {
return nil, fmt.Errorf("name is required")
}
if o.Color == "" {
return nil, fmt.Errorf("color is required (upstream Category model validates presence)")
}
if !categoryHexColorRE.MatchString(o.Color) {
return nil, fmt.Errorf("color must match #RRGGBB hex format, got %q", o.Color)
}
// Send the trimmed value — otherwise `--name " Food "` passes presence
// validation here but the surrounding whitespace leaks into the payload
// and would clash with the upstream uniqueness check against "Food".
cat := map[string]any{
"name": name,
"color": o.Color,
}
if o.Icon != "" {
// Upstream maps :icon -> :lucide_icon in category_params; send the
// original key so server-side handling remains the single source of truth.
cat["icon"] = o.Icon
}
if o.ParentID != "" {
cat["parent_id"] = o.ParentID
}
return map[string]any{"category": cat}, nil
}

func newCategoriesCreateCmd() *cobra.Command {
var o categoryCreateOpts
cmd := &cobra.Command{
Use: "create",
Short: "Create a category (default dry-run; use --apply to execute)",
Run: func(cmd *cobra.Command, args []string) {
payload, err := buildCategoryCreatePayload(o)
if err != nil {
failValidation(err)
}
path := "/api/v1/categories"
if !o.Apply {
printDryRun("POST", path, payload)
return
}
printPost(path, payload)
},
}
cmd.Flags().StringVar(&o.Name, "name", "", "category name (required, unique within family)")
cmd.Flags().StringVar(&o.Color, "color", "", "hex color (#RRGGBB, required)")
cmd.Flags().StringVar(&o.Icon, "icon", "", "lucide icon name (optional; upstream auto-suggests one if omitted)")
cmd.Flags().StringVar(&o.ParentID, "parent-id", "", "parent category id (must belong to your family)")
cmd.Flags().BoolVar(&o.Apply, "apply", false, "execute the create (otherwise dry-run)")
return cmd
}

Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Future features and ideas for `sure-cli`.
- Import dry-run validation via `imports preflight`.
- CSV and Sure NDJSON import creation via `imports create`.
- Family export list/show/create/download commands.
- Category creation via `categories create --name --color [--icon --parent-id]`.
- Transfer review surface via `transfers list/show` and `rejected-transfers list/show`.
- Sync history surface via `syncs list/latest/show`.
- API usage / rate-limit visibility via `usage show`.
Expand Down
Loading