summaryrefslogtreecommitdiff
path: root/internal/handlers/shopping_sources_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-20 07:47:48 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-20 07:47:48 +0000
commitc5ab2f9a7f6a0b1e456e13934268d1509504c55a (patch)
treebc1e74b6b07341246aaa71ee498b6c3513e2231d /internal/handlers/shopping_sources_test.go
parentb970be11ee895c34e019231022e9e5244e4aec2f (diff)
Drop Trello as a shopping source, importing the two groups worth keeping
The Trello "Shopping" board had become a dumping ground: of 24 cards, 13 had no list at all (rendering as "Uncategorized"), and the rest were spread across Costco, Hardware Store, "Perennial " (trailing space, so a separate store from "Perennial") and online. Its items were read-only in doot — no rename, no move, no delete, only hide — and its list names were doubling as an accidental store registry, which is the only reason stores like Hardware Store existed at all. Migration 032 imports the two groups asked for, Hardware Store and the unlisted cards, into user_shopping_items. Costco, Perennial and online cards are left behind. Nothing is destroyed: the cards remain in Trello, this only stops doot displaying them. Imported rows become ordinary user items, so they also become editable, which Trello-sourced items never were. Trello remains the source for the other eight boards; this is only the Shopping board feeding the shopping list. Also updated the quick-action modal's Shopping tab, which posted source=trello with a Trello list_id. Left alone it would have kept creating cards that no longer appear anywhere. It now posts source=shopping with a store name, handled by a new case in HandleUnifiedAdd, and its picker reads from a new /partials/shopping-stores endpoint. The Trello-lists endpoint it used to call is deleted along with its route. Note for the edit feature still to come: stores have no existence independent of items, so a store with nothing in it cannot be chosen. That was previously masked by Trello's lists persisting. "Move between stores and sections" needs a real stores/sections table. Migration tested against a seeded snapshot of the real board, including the trailing-space store name, other boards, replay safety and the empty-cache no-op. Two existing tests asserting Trello check-state in shopping mode were updated to plantoeat, the only remaining external source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/handlers/shopping_sources_test.go')
-rw-r--r--internal/handlers/shopping_sources_test.go159
1 files changed, 159 insertions, 0 deletions
diff --git a/internal/handlers/shopping_sources_test.go b/internal/handlers/shopping_sources_test.go
new file mode 100644
index 0000000..517f4b4
--- /dev/null
+++ b/internal/handlers/shopping_sources_test.go
@@ -0,0 +1,159 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "task-dashboard/internal/models"
+)
+
+// The Trello "Shopping" board is no longer a source for the shopping list. It
+// had become a dumping ground, its items were read-only in doot (no rename, no
+// move, no delete — only hide), and its list names were doubling as an
+// accidental store registry. The two groups worth keeping were imported into
+// user items by migration 032; the rest simply stop being displayed. The cards
+// themselves are untouched in Trello.
+func TestAggregateShoppingLists_IgnoresTrelloShoppingBoard(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.SaveBoards([]models.Board{{
+ ID: "shop-board",
+ Name: "Shopping",
+ Cards: []models.Card{
+ {ID: "c1", Name: "Broccoli", ListName: ""},
+ {ID: "c2", Name: "Wasp traps", ListName: "Hardware Store"},
+ {ID: "c3", Name: "Pearl Waffles", ListName: "Costco"},
+ },
+ }}); err != nil {
+ t.Fatalf("seeding boards: %v", err)
+ }
+
+ stores := h.aggregateShoppingLists(context.Background())
+
+ for _, st := range stores {
+ for _, cat := range st.Categories {
+ for _, it := range cat.Items {
+ if it.Source == "trello" {
+ t.Errorf("trello item %q leaked into the shopping list (store %q)", it.Name, st.Name)
+ }
+ }
+ }
+ }
+}
+
+// User-added items are still aggregated — this is the source that remains, so
+// a regression here would empty the list entirely.
+func TestAggregateShoppingLists_IncludesUserItems(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.SaveUserShoppingItem("Milk", "Costco"); err != nil {
+ t.Fatalf("saving user item: %v", err)
+ }
+
+ stores := h.aggregateShoppingLists(context.Background())
+
+ var found bool
+ for _, st := range stores {
+ for _, cat := range st.Categories {
+ for _, it := range cat.Items {
+ if it.Name == "Milk" && it.Source == "user" && st.Name == "Costco" {
+ found = true
+ }
+ }
+ }
+ }
+ if !found {
+ t.Errorf("user item not found in aggregated list; got %+v", stores)
+ }
+}
+
+// A Shopping board with cards must not conjure stores into existence. Store
+// names used to come partly from Trello list names, which is why removing the
+// source changes which stores exist at all.
+func TestAggregateShoppingLists_TrelloListsDoNotCreateStores(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.SaveBoards([]models.Board{{
+ ID: "shop-board",
+ Name: "Shopping",
+ Cards: []models.Card{
+ {ID: "c1", Name: "Gas valve", ListName: "Hardware Store"},
+ },
+ }}); err != nil {
+ t.Fatalf("seeding boards: %v", err)
+ }
+
+ for _, st := range h.aggregateShoppingLists(context.Background()) {
+ if st.Name == "Hardware Store" {
+ t.Error(`"Hardware Store" store exists only because of a Trello list name`)
+ }
+ }
+}
+
+// The quick-action modal's Shopping tab posts source=shopping. It used to post
+// source=trello with a list_id, which after the Trello removal would have
+// created a card nobody could see.
+func TestUnifiedAdd_ShoppingSourceCreatesUserItem(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ form := url.Values{"title": {"Sponges"}, "source": {"shopping"}, "store": {"Costco"}}
+ req := httptest.NewRequest(http.MethodPost, "/unified-add", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+
+ h.HandleUnifiedAdd(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("got status %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ items, err := h.store.GetUserShoppingItems()
+ if err != nil {
+ t.Fatalf("reading items: %v", err)
+ }
+ if len(items) != 1 || items[0].Name != "Sponges" || items[0].Store != "Costco" {
+ t.Errorf("got %+v, want one Sponges/Costco item", items)
+ }
+}
+
+func TestUnifiedAdd_ShoppingRequiresAStore(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ form := url.Values{"title": {"Sponges"}, "source": {"shopping"}}
+ req := httptest.NewRequest(http.MethodPost, "/unified-add", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+
+ h.HandleUnifiedAdd(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("got status %d, want 400 when no store is given", rec.Code)
+ }
+}
+
+// The store picker must offer the stores that actually exist.
+func TestHandleGetShoppingStores_ReturnsOptions(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ _ = h.store.SaveUserShoppingItem("Milk", "Costco")
+ _ = h.store.SaveUserShoppingItem("Nails", "Hardware Store")
+
+ rec := httptest.NewRecorder()
+ h.HandleGetShoppingStores(rec, httptest.NewRequest(http.MethodGet, "/partials/shopping-stores", nil))
+
+ body := rec.Body.String()
+ for _, want := range []string{`<option value="Costco">Costco</option>`, `<option value="Hardware Store">Hardware Store</option>`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("missing %q in %q", want, body)
+ }
+ }
+}