From ff4f191f161712fb9485c20758bfc9521a29a7aa Mon Sep 17 00:00:00 2001 From: David Gil Date: Sat, 23 May 2026 21:19:51 +0200 Subject: [PATCH 1/2] feat(categories): add `categories create` to complete the v1 surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last `/api/v1` write endpoint that was unexposed in the CLI: `POST /api/v1/categories` (Api::V1::CategoriesController#create). Body matches the upstream `params.require(:category).permit(:name, :color, :icon, :parent_id)` shape (icon is mapped to lucide_icon server-side, so we keep the original key). Client-side validation matches the Category model: - `--name` required (server-side: presence + uniqueness per family). - `--color` required and validated against `^#[0-9A-Fa-f]{6}$` — same regex the model uses — so users get fast feedback before a 422. - `--icon` optional; upstream auto-suggests via `Category.suggested_icon(name)` when blank. - `--parent-id` optional; upstream verifies it belongs to the family and 422s otherwise. 8 unit tests cover: missing name, missing color, invalid hex formats (short, non-hex, trailing space), accepted hex formats, payload wraps in `{category: ...}`, optional fields propagate, whitespace-only name rejected, and command registration with the full flag set. After this lands the CLI mirrors every `/api/v1` endpoint that is meaningfully usable from a shell. The remaining auth endpoints (`signup`, `sso_*`) are intentionally not wrapped — they are mobile-device-bound flows that don't fit a CLI. Refs we-promise/sure-cli#11. --- README.md | 3 + cmd/sure-cli/root/categories_create_test.go | 96 +++++++++++++++++++++ cmd/sure-cli/root/reference_cmds.go | 69 +++++++++++++++ docs/ROADMAP.md | 1 + 4 files changed, 169 insertions(+) create mode 100644 cmd/sure-cli/root/categories_create_test.go diff --git a/README.md b/README.md index fdf4840..9bf7867 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,9 @@ sure-cli family-exports download --out sure-export.zip # Reference data and rules sure-cli categories list --roots-only +sure-cli categories show +sure-cli categories create --name Food --color '#3b82f6' +sure-cli categories create --name Food --color '#3b82f6' --icon utensils --parent-id --apply sure-cli merchants list sure-cli tags create --name Travel --color '#3b82f6' sure-cli tags create --name Travel --color '#3b82f6' --apply diff --git a/cmd/sure-cli/root/categories_create_test.go b/cmd/sure-cli/root/categories_create_test.go new file mode 100644 index 0000000..692e017 --- /dev/null +++ b/cmd/sure-cli/root/categories_create_test.go @@ -0,0 +1,96 @@ +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 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) + } + } +} diff --git a/cmd/sure-cli/root/reference_cmds.go b/cmd/sure-cli/root/reference_cmds.go index afa2cda..9bb0852 100644 --- a/cmd/sure-cli/root/reference_cmds.go +++ b/cmd/sure-cli/root/reference_cmds.go @@ -3,6 +3,8 @@ package root import ( "fmt" "net/url" + "regexp" + "strings" "github.com/spf13/cobra" "github.com/we-promise/sure-cli/internal/api" @@ -44,6 +46,73 @@ 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) + } + cat := map[string]any{ + "name": o.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 } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7208c38..9c8c815 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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`. From 424e29c4d37d9ead3b2c921ca215a8c9a9611525 Mon Sep 17 00:00:00 2001 From: David Gil Date: Sat, 23 May 2026 21:38:01 +0200 Subject: [PATCH 2/2] fix(categories): send trimmed name in create payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildCategoryCreatePayload trimmed whitespace before the presence check but then sent the original `o.Name` in the payload. With `--name ' Food '` that meant the request body included the spaces even though validation looked at the trimmed value — which would clash with the upstream uniqueness check against an existing 'Food'. Send the trimmed value to match validation. Adds regression test TestBuildCategoryCreatePayload_TrimsNameInPayload. --- cmd/sure-cli/root/categories_create_test.go | 14 ++++++++++++++ cmd/sure-cli/root/reference_cmds.go | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cmd/sure-cli/root/categories_create_test.go b/cmd/sure-cli/root/categories_create_test.go index 692e017..54e60fd 100644 --- a/cmd/sure-cli/root/categories_create_test.go +++ b/cmd/sure-cli/root/categories_create_test.go @@ -79,6 +79,20 @@ func TestBuildCategoryCreatePayload_WhitespaceOnlyNameRejected(t *testing.T) { } } +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"}) diff --git a/cmd/sure-cli/root/reference_cmds.go b/cmd/sure-cli/root/reference_cmds.go index 9bb0852..a73edf6 100644 --- a/cmd/sure-cli/root/reference_cmds.go +++ b/cmd/sure-cli/root/reference_cmds.go @@ -75,8 +75,11 @@ func buildCategoryCreatePayload(o categoryCreateOpts) (map[string]any, error) { 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": o.Name, + "name": name, "color": o.Color, } if o.Icon != "" {