From 8e7e0064d6370718a3ed344f6f25739c05fe99ed Mon Sep 17 00:00:00 2001 From: team-humaki Date: Sat, 12 Sep 2026 20:26:00 -0700 Subject: [PATCH] feat(store): case-insensitive vault name matching Lookup and delete vaults by name ignoring case for the same user. Fixes #59. --- internal/server/http/http.go | 1 - internal/server/http/http_test.go | 44 +++++++++++++++++ internal/server/store/store.go | 10 ++-- internal/server/store/store_test.go | 77 +++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 5 deletions(-) diff --git a/internal/server/http/http.go b/internal/server/http/http.go index 1581a6f..b17c215 100644 --- a/internal/server/http/http.go +++ b/internal/server/http/http.go @@ -150,7 +150,6 @@ func (h *Handler) VaultByName(w http.ResponseWriter, r *http.Request) { return } - // TODO: Implement case insensitive name matching (will need modifiying in store) name := r.PathValue("name") if name == "" { http.Error(w, "name is required", http.StatusBadRequest) diff --git a/internal/server/http/http_test.go b/internal/server/http/http_test.go index 497ee9e..3344ff1 100644 --- a/internal/server/http/http_test.go +++ b/internal/server/http/http_test.go @@ -157,6 +157,12 @@ func TestCreateVaultEndpoint(t *testing.T) { input: `{"name":"notes"}`, wantStatus: http.StatusConflict, }, + { + name: "duplicate name different case", + seed: `{"name":"Notes"}`, + input: `{"name":"notes"}`, + wantStatus: http.StatusConflict, + }, } for _, test := range tests { @@ -309,6 +315,26 @@ func TestVaultByNameEndpoint(t *testing.T) { } }) + t.Run("found case insensitive", func(t *testing.T) { + for _, name := range []string{"notes", "NOTES", "Notes"} { + rec := get(name) + if rec.Code != http.StatusOK { + t.Errorf("GET %q: expected status code %d, got %d (body: %s)", + name, http.StatusOK, rec.Code, rec.Body.String()) + continue + } + + var got store.Vault + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Errorf("GET %q: decode response: %v (body: %s)", name, err, rec.Body.String()) + continue + } + if got.Name != "notes" { + t.Errorf("GET %q: Name = %q, want %q (stored casing)", name, got.Name, "notes") + } + } + }) + t.Run("not found", func(t *testing.T) { rec := get("does-not-exist") if rec.Code != http.StatusNotFound { @@ -379,6 +405,24 @@ func TestDeleteVaultEndpoint(t *testing.T) { } }) + t.Run("deletes matching name case insensitively", func(t *testing.T) { + if _, err := s.CreateVault(context.Background(), user.ID, "Journal"); err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + + rec := del("JOURNAL") + if rec.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d (body: %s)", http.StatusOK, rec.Code, rec.Body.String()) + } + if got, want := rec.Body.String(), "Vault deleted"; got != want { + t.Errorf("body = %q, want %q", got, want) + } + + if _, err := s.VaultByName(context.Background(), user.ID, "journal"); !errors.Is(err, store.ErrVaultNotFound) { + t.Errorf("VaultByName() after case-insensitive delete = %v, want ErrVaultNotFound", err) + } + }) + t.Run("deleting a nonexistent vault is idempotent but reports nothing was deleted", func(t *testing.T) { rec := del("does-not-exist") if rec.Code != http.StatusOK { diff --git a/internal/server/store/store.go b/internal/server/store/store.go index 31eeba0..8a5f449 100644 --- a/internal/server/store/store.go +++ b/internal/server/store/store.go @@ -77,8 +77,10 @@ func (s *Store) CreateVault(ctx context.Context, userID, name string) (Vault, er err := s.db.QueryRowContext(ctx, ` INSERT INTO vaults (id, user_id, name, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id, name) DO NOTHING + SELECT $1, $2, $3, $4, $5 + WHERE NOT EXISTS ( + SELECT 1 FROM vaults WHERE user_id = $2 AND LOWER(name) = LOWER($3) + ) RETURNING id`, vault.ID, userID, name, vault.CreatedAt, vault.UpdatedAt, ).Scan(&vault.ID) @@ -96,7 +98,7 @@ func (s *Store) CreateVault(ctx context.Context, userID, name string) (Vault, er func (s *Store) VaultByName(ctx context.Context, userID, name string) (Vault, error) { var vault Vault - err := s.db.QueryRowContext(ctx, "SELECT id, name, created_at, updated_at FROM vaults WHERE user_id = $1 AND name = $2", userID, name).Scan(&vault.ID, &vault.Name, &vault.CreatedAt, &vault.UpdatedAt) + err := s.db.QueryRowContext(ctx, "SELECT id, name, created_at, updated_at FROM vaults WHERE user_id = $1 AND LOWER(name) = LOWER($2)", userID, name).Scan(&vault.ID, &vault.Name, &vault.CreatedAt, &vault.UpdatedAt) if err != nil { if err == sql.ErrNoRows { return Vault{}, ErrVaultNotFound @@ -129,7 +131,7 @@ func (s *Store) ListVaults(ctx context.Context, userID string) ([]Vault, error) } func (s *Store) DeleteVault(ctx context.Context, userID, name string) (bool, error) { - res, err := s.db.ExecContext(ctx, "DELETE FROM vaults WHERE user_id = $1 AND name = $2", userID, name) + res, err := s.db.ExecContext(ctx, "DELETE FROM vaults WHERE user_id = $1 AND LOWER(name) = LOWER($2)", userID, name) if err != nil { return false, fmt.Errorf("store: delete vault: %w", err) } diff --git a/internal/server/store/store_test.go b/internal/server/store/store_test.go index 2b83d8c..0014452 100644 --- a/internal/server/store/store_test.go +++ b/internal/server/store/store_test.go @@ -64,6 +64,24 @@ func TestCreateVaultDuplicateName(t *testing.T) { if _, err := s.CreateVault(context.Background(), user.ID, "notes"); err == nil { t.Error("CreateVault() with a name the user already has expected an error, got nil") + } else if !errors.Is(err, ErrVaultExists) { + t.Errorf("CreateVault() error = %v, want ErrVaultExists", err) + } +} + +func TestCreateVaultDuplicateNameDifferentCase(t *testing.T) { + s := newTestStore(t) + user := seedUser(t, s, "a@example.com") + + if _, err := s.CreateVault(context.Background(), user.ID, "Notes"); err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + + if _, err := s.CreateVault(context.Background(), user.ID, "notes"); !errors.Is(err, ErrVaultExists) { + t.Errorf("CreateVault() with a case-insensitive duplicate = %v, want ErrVaultExists", err) + } + if _, err := s.CreateVault(context.Background(), user.ID, "NOTES"); !errors.Is(err, ErrVaultExists) { + t.Errorf("CreateVault() with a case-insensitive duplicate = %v, want ErrVaultExists", err) } } @@ -80,6 +98,19 @@ func TestCreateVaultSameNameDifferentUsers(t *testing.T) { } } +func TestCreateVaultSameNameDifferentCaseDifferentUsers(t *testing.T) { + s := newTestStore(t) + userA := seedUser(t, s, "a@example.com") + userB := seedUser(t, s, "b@example.com") + + if _, err := s.CreateVault(context.Background(), userA.ID, "notes"); err != nil { + t.Fatalf("CreateVault(userA): %v", err) + } + if _, err := s.CreateVault(context.Background(), userB.ID, "NOTES"); err != nil { + t.Errorf("CreateVault(userB) with a different-case name as userA's vault: %v", err) + } +} + func TestVaultByName(t *testing.T) { s := newTestStore(t) user := seedUser(t, s, "a@example.com") @@ -101,6 +132,30 @@ func TestVaultByName(t *testing.T) { } } +func TestVaultByNameCaseInsensitive(t *testing.T) { + s := newTestStore(t) + user := seedUser(t, s, "a@example.com") + + created, err := s.CreateVault(context.Background(), user.ID, "Notes") + if err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + + for _, name := range []string{"Notes", "notes", "NOTES", "nOtEs"} { + got, err := s.VaultByName(context.Background(), user.ID, name) + if err != nil { + t.Errorf("VaultByName(%q) error: %v", name, err) + continue + } + if got.ID != created.ID { + t.Errorf("VaultByName(%q) ID = %q, want %q", name, got.ID, created.ID) + } + if got.Name != "Notes" { + t.Errorf("VaultByName(%q) Name = %q, want %q (stored casing)", name, got.Name, "Notes") + } + } +} + func TestVaultByNameNotFound(t *testing.T) { s := newTestStore(t) user := seedUser(t, s, "a@example.com") @@ -192,6 +247,28 @@ func TestDeleteVault(t *testing.T) { } } +func TestDeleteVaultCaseInsensitive(t *testing.T) { + s := newTestStore(t) + user := seedUser(t, s, "a@example.com") + + if _, err := s.CreateVault(context.Background(), user.ID, "Notes"); err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + + deleted, err := s.DeleteVault(context.Background(), user.ID, "NOTES") + if err != nil { + t.Fatalf("DeleteVault() error: %v", err) + } + if !deleted { + t.Errorf("DeleteVault() = false, want true") + } + + _, err = s.VaultByName(context.Background(), user.ID, "notes") + if !errors.Is(err, ErrVaultNotFound) { + t.Errorf("VaultByName() after case-insensitive delete = %v, want ErrVaultNotFound", err) + } +} + func TestDeleteVaultScopedToUser(t *testing.T) { s := newTestStore(t) userA := seedUser(t, s, "a@example.com")