summaryrefslogtreecommitdiff
path: root/internal/store/shopping_registry_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store/shopping_registry_test.go')
-rw-r--r--internal/store/shopping_registry_test.go302
1 files changed, 302 insertions, 0 deletions
diff --git a/internal/store/shopping_registry_test.go b/internal/store/shopping_registry_test.go
new file mode 100644
index 0000000..b8ed79e
--- /dev/null
+++ b/internal/store/shopping_registry_test.go
@@ -0,0 +1,302 @@
+package store
+
+import (
+ "database/sql"
+ "errors"
+ "path/filepath"
+ "testing"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+func setupRegistryStore(t *testing.T) *Store {
+ t.Helper()
+ db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+ if _, err := db.Exec(`
+ PRAGMA foreign_keys = ON;
+ CREATE TABLE user_shopping_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ store TEXT NOT NULL,
+ section TEXT NOT NULL DEFAULT '',
+ checked INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE shopping_stores (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE shopping_sections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ store_id INTEGER NOT NULL REFERENCES shopping_stores(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (store_id, name)
+ );`); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ return &Store{db: db}
+}
+
+// The registry's whole purpose: a store exists whether or not anything is in
+// it, so you can move an item *to* an empty store.
+func TestShoppingStores_ExistWithoutItems(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ if err := s.AddShoppingStore("Hardware Store"); err != nil {
+ t.Fatalf("add: %v", err)
+ }
+ names, err := s.ShoppingStoreNames()
+ if err != nil {
+ t.Fatalf("names: %v", err)
+ }
+ if len(names) != 1 || names[0] != "Hardware Store" {
+ t.Errorf("got %v, want [Hardware Store]", names)
+ }
+}
+
+func TestShoppingStores_AddIsIdempotent(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddShoppingStore("Costco")
+ if err := s.AddShoppingStore("Costco"); err != nil {
+ t.Fatalf("re-add should be a no-op, got: %v", err)
+ }
+ names, _ := s.ShoppingStoreNames()
+ if len(names) != 1 {
+ t.Errorf("got %d stores, want 1", len(names))
+ }
+}
+
+func TestShoppingStores_AddRejectsBlank(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+ if err := s.AddShoppingStore(" "); err == nil {
+ t.Error("expected an error for a blank store name")
+ }
+}
+
+// Items reference a store by name, so a rename must carry them along or every
+// item in that store is orphaned.
+func TestShoppingStores_RenameMovesItsItems(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddShoppingStore("Hardware")
+ _ = s.SaveUserShoppingItem("Nails", "Hardware")
+ _ = s.SaveUserShoppingItem("Milk", "Costco")
+
+ stores, _ := s.GetShoppingStores()
+ if err := s.RenameShoppingStore(stores[0].ID, "Hardware Store"); err != nil {
+ t.Fatalf("rename: %v", err)
+ }
+
+ items, _ := s.GetUserShoppingItems()
+ for _, it := range items {
+ if it.Name == "Nails" && it.Store != "Hardware Store" {
+ t.Errorf("item stayed on the old store name: %+v", it)
+ }
+ if it.Name == "Milk" && it.Store != "Costco" {
+ t.Errorf("an unrelated item was moved: %+v", it)
+ }
+ }
+}
+
+func TestShoppingStores_RenameUnknownStore(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+ if err := s.RenameShoppingStore(999, "Nope"); !errors.Is(err, ErrStoreNotFound) {
+ t.Errorf("got %v, want ErrStoreNotFound", err)
+ }
+}
+
+// Deleting a store must not take its items with it — losing the shopping list
+// is a much worse outcome than an item needing to be re-filed.
+func TestShoppingStores_DeleteKeepsItems(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddShoppingStore("Hardware")
+ _ = s.SaveUserShoppingItemInSection("Nails", "Hardware", "Fasteners")
+
+ stores, _ := s.GetShoppingStores()
+ if err := s.DeleteShoppingStore(stores[0].ID); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+
+ items, _ := s.GetUserShoppingItems()
+ if len(items) != 1 {
+ t.Fatalf("got %d items, want the item kept", len(items))
+ }
+ if items[0].Store != "Unassigned" || items[0].Section != "" {
+ t.Errorf("got %+v, want it moved to Unassigned with no section", items[0])
+ }
+}
+
+func TestShoppingSections_AddedAndReturnedWithTheirStore(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddShoppingStore("Costco")
+ stores, _ := s.GetShoppingStores()
+ if err := s.AddShoppingSection(stores[0].ID, "Produce"); err != nil {
+ t.Fatalf("add section: %v", err)
+ }
+ _ = s.AddShoppingSection(stores[0].ID, "Frozen")
+
+ stores, _ = s.GetShoppingStores()
+ if len(stores[0].Sections) != 2 {
+ t.Fatalf("got %d sections, want 2", len(stores[0].Sections))
+ }
+}
+
+// The same section name in two different stores is normal ("Produce" exists in
+// both) and must not collide.
+func TestShoppingSections_SameNameInDifferentStores(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddShoppingStore("Costco")
+ _ = s.AddShoppingStore("Safeway")
+ stores, _ := s.GetShoppingStores()
+ for _, st := range stores {
+ if err := s.AddShoppingSection(st.ID, "Produce"); err != nil {
+ t.Fatalf("add section to %s: %v", st.Name, err)
+ }
+ }
+ stores, _ = s.GetShoppingStores()
+ for _, st := range stores {
+ if len(st.Sections) != 1 {
+ t.Errorf("%s has %d sections, want 1", st.Name, len(st.Sections))
+ }
+ }
+}
+
+func TestShoppingSections_DeleteClearsItFromItems(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddShoppingStore("Costco")
+ stores, _ := s.GetShoppingStores()
+ _ = s.AddShoppingSection(stores[0].ID, "Produce")
+ _ = s.SaveUserShoppingItemInSection("Kale", "Costco", "Produce")
+
+ stores, _ = s.GetShoppingStores()
+ if err := s.DeleteShoppingSection(stores[0].Sections[0].ID); err != nil {
+ t.Fatalf("delete section: %v", err)
+ }
+
+ items, _ := s.GetUserShoppingItems()
+ if len(items) != 1 {
+ t.Fatalf("item was deleted along with its section")
+ }
+ if items[0].Section != "" {
+ t.Errorf("Section = %q, want cleared", items[0].Section)
+ }
+ if items[0].Store != "Costco" {
+ t.Errorf("Store = %q, want unchanged", items[0].Store)
+ }
+}
+
+// Deleting a store's section must cascade, not leave orphaned rows pointing at
+// a store that no longer exists.
+func TestShoppingSections_CascadeWhenStoreDeleted(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.AddShoppingStore("Costco")
+ stores, _ := s.GetShoppingStores()
+ _ = s.AddShoppingSection(stores[0].ID, "Produce")
+
+ if err := s.DeleteShoppingStore(stores[0].ID); err != nil {
+ t.Fatalf("delete store: %v", err)
+ }
+ var n int
+ if err := s.db.QueryRow(`SELECT COUNT(*) FROM shopping_sections`).Scan(&n); err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if n != 0 {
+ t.Errorf("%d orphaned sections remain", n)
+ }
+}
+
+// ── item editing ─────────────────────────────────────────────────────────────
+
+func TestUpdateUserShoppingItem_RenamesAndMoves(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.SaveUserShoppingItemInSection("Mlik", "Costco", "Dairy")
+ items, _ := s.GetUserShoppingItems()
+
+ if err := s.UpdateUserShoppingItem(items[0].ID, "Milk", "Safeway", "Refrigerated"); err != nil {
+ t.Fatalf("update: %v", err)
+ }
+
+ got, err := s.GetUserShoppingItem(items[0].ID)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.Name != "Milk" || got.Store != "Safeway" || got.Section != "Refrigerated" {
+ t.Errorf("got %+v, want renamed and moved", got)
+ }
+}
+
+func TestUpdateUserShoppingItem_ClearingTheSection(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.SaveUserShoppingItemInSection("Kale", "Costco", "Produce")
+ items, _ := s.GetUserShoppingItems()
+
+ if err := s.UpdateUserShoppingItem(items[0].ID, "Kale", "Costco", ""); err != nil {
+ t.Fatalf("update: %v", err)
+ }
+ got, _ := s.GetUserShoppingItem(items[0].ID)
+ if got.Section != "" {
+ t.Errorf("Section = %q, want cleared", got.Section)
+ }
+}
+
+// A SQL UPDATE that matches nothing still succeeds, so a stale id would look
+// like a successful edit and the user would think the change stuck.
+func TestUpdateUserShoppingItem_UnknownIDIsAnError(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ if err := s.UpdateUserShoppingItem(999, "Ghost", "Costco", ""); !errors.Is(err, ErrItemNotFound) {
+ t.Errorf("got %v, want ErrItemNotFound", err)
+ }
+}
+
+func TestUpdateUserShoppingItem_RejectsBlankNameOrStore(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+
+ _ = s.SaveUserShoppingItem("Milk", "Costco")
+ items, _ := s.GetUserShoppingItems()
+
+ if err := s.UpdateUserShoppingItem(items[0].ID, " ", "Costco", ""); err == nil {
+ t.Error("expected an error for a blank name")
+ }
+ if err := s.UpdateUserShoppingItem(items[0].ID, "Milk", " ", ""); err == nil {
+ t.Error("expected an error for a blank store")
+ }
+}
+
+func TestGetUserShoppingItem_NotFound(t *testing.T) {
+ s := setupRegistryStore(t)
+ defer func() { _ = s.Close() }()
+ if _, err := s.GetUserShoppingItem(999); !errors.Is(err, ErrItemNotFound) {
+ t.Errorf("got %v, want ErrItemNotFound", err)
+ }
+}