diff options
Diffstat (limited to 'internal/store/shopping_registry_test.go')
| -rw-r--r-- | internal/store/shopping_registry_test.go | 302 |
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) + } +} |
