summaryrefslogtreecommitdiff
path: root/internal/store
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/store
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/store')
-rw-r--r--internal/store/shopping_import_test.go229
1 files changed, 229 insertions, 0 deletions
diff --git a/internal/store/shopping_import_test.go b/internal/store/shopping_import_test.go
new file mode 100644
index 0000000..0e246d8
--- /dev/null
+++ b/internal/store/shopping_import_test.go
@@ -0,0 +1,229 @@
+package store
+
+import (
+ "database/sql"
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+// Runs migrations/032 against a database seeded with a realistic snapshot of
+// the Trello Shopping board, and asserts it imports exactly the two groups
+// that were asked for and leaves the rest behind.
+//
+// The migration is executed from the real file rather than a copy, so the
+// thing under test is what actually ships.
+func TestMigration032_ImportsOnlyHardwareStoreAndUncategorized(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ seedShoppingBoard(t, db, []card{
+ // Unlisted cards — these render as "Uncategorized" and are imported.
+ {"Broccoli", ""},
+ {"Ibuprofen", ""},
+ {"dog water bowl", ""},
+ // Hardware Store — imported.
+ {"6x8 plywood", "Hardware Store"},
+ {"Wasp traps", "Hardware Store"},
+ // Deliberately left behind.
+ {"Pearl Waffles", "Costco"},
+ {"Pita Bread", "Perennial "},
+ {"Rip stop", "online"},
+ })
+
+ runMigration032(t, db)
+
+ got := userItems(t, db)
+ want := []item{
+ {"6x8 plywood", "Hardware Store"},
+ {"Broccoli", "Uncategorized"},
+ {"Ibuprofen", "Uncategorized"},
+ {"Wasp traps", "Hardware Store"},
+ {"dog water bowl", "Uncategorized"},
+ }
+ assertItems(t, got, want)
+}
+
+// A card whose list has surrounding whitespace must not create a store that
+// differs from the trimmed name only by invisible characters. "Perennial " on
+// the real board was exactly this.
+func TestMigration032_TrimsStoreNames(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ seedShoppingBoard(t, db, []card{
+ {"Trimmed", " Hardware Store "},
+ })
+ runMigration032(t, db)
+
+ got := userItems(t, db)
+ assertItems(t, got, []item{{"Trimmed", "Hardware Store"}})
+}
+
+// Cards on other boards must not be swept in — only the Shopping board.
+func TestMigration032_IgnoresOtherBoards(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ seedBoard(t, db, "camping", "Camping")
+ mustExec(t, db, `INSERT INTO cards (id, name, board_id, list_name) VALUES ('c1','Tent pegs','camping','')`)
+ seedShoppingBoard(t, db, []card{{"Broccoli", ""}})
+
+ runMigration032(t, db)
+
+ got := userItems(t, db)
+ assertItems(t, got, []item{{"Broccoli", "Uncategorized"}})
+}
+
+// Replaying the migration must not duplicate rows.
+func TestMigration032_IsReplaySafe(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ seedShoppingBoard(t, db, []card{{"Broccoli", ""}, {"Wasp traps", "Hardware Store"}})
+
+ runMigration032(t, db)
+ runMigration032(t, db)
+
+ got := userItems(t, db)
+ assertItems(t, got, []item{
+ {"Broccoli", "Uncategorized"},
+ {"Wasp traps", "Hardware Store"},
+ })
+}
+
+// An item the user already added by hand must not gain a duplicate.
+func TestMigration032_SkipsItemsAlreadyPresent(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ mustExec(t, db, `INSERT INTO user_shopping_items (name, store) VALUES ('Wasp traps','Hardware Store')`)
+ seedShoppingBoard(t, db, []card{{"Wasp traps", "Hardware Store"}})
+
+ runMigration032(t, db)
+
+ assertItems(t, userItems(t, db), []item{{"Wasp traps", "Hardware Store"}})
+}
+
+// On a database with no cached Trello cards the migration is a no-op rather
+// than an error — that is the state of any fresh dev database.
+func TestMigration032_NoOpWithNoCards(t *testing.T) {
+ db := setupImportTestDB(t)
+ defer func() { _ = db.Close() }()
+
+ runMigration032(t, db)
+
+ if got := userItems(t, db); len(got) != 0 {
+ t.Errorf("expected no items, got %v", got)
+ }
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+type card struct{ name, listName string }
+type item struct{ Name, Store string }
+
+func setupImportTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "import.db"))
+ if err != nil {
+ t.Fatalf("open test db: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+ mustExec(t, db, `
+ CREATE TABLE boards (id TEXT PRIMARY KEY, name TEXT NOT NULL);
+ CREATE TABLE cards (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ board_id TEXT NOT NULL,
+ list_name TEXT
+ );
+ CREATE TABLE user_shopping_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ store TEXT NOT NULL,
+ checked INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ `)
+ return db
+}
+
+func seedBoard(t *testing.T, db *sql.DB, id, name string) {
+ t.Helper()
+ mustExec(t, db, `INSERT INTO boards (id, name) VALUES (?, ?)`, id, name)
+}
+
+func seedShoppingBoard(t *testing.T, db *sql.DB, cards []card) {
+ t.Helper()
+ seedBoard(t, db, "shop", "Shopping")
+ for i, c := range cards {
+ mustExec(t, db,
+ `INSERT INTO cards (id, name, board_id, list_name) VALUES (?, ?, 'shop', ?)`,
+ "card-"+string(rune('a'+i)), c.name, c.listName)
+ }
+}
+
+func runMigration032(t *testing.T, db *sql.DB) {
+ t.Helper()
+ path := filepath.Join("..", "..", "migrations", "032_import_trello_shopping_items.sql")
+ sqlBytes, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading migration: %v", err)
+ }
+ if _, err := db.Exec(string(sqlBytes)); err != nil {
+ t.Fatalf("executing migration: %v", err)
+ }
+}
+
+func userItems(t *testing.T, db *sql.DB) []item {
+ t.Helper()
+ rows, err := db.Query(`SELECT name, store FROM user_shopping_items`)
+ if err != nil {
+ t.Fatalf("querying items: %v", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ var out []item
+ for rows.Next() {
+ var it item
+ if err := rows.Scan(&it.Name, &it.Store); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ out = append(out, it)
+ }
+ return out
+}
+
+func assertItems(t *testing.T, got, want []item) {
+ t.Helper()
+ sortItems(got)
+ sortItems(want)
+ if len(got) != len(want) {
+ t.Fatalf("got %d items %v, want %d %v", len(got), got, len(want), want)
+ }
+ for i := range got {
+ if got[i] != want[i] {
+ t.Errorf("item %d: got %+v, want %+v", i, got[i], want[i])
+ }
+ }
+}
+
+func sortItems(items []item) {
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].Store != items[j].Store {
+ return items[i].Store < items[j].Store
+ }
+ return items[i].Name < items[j].Name
+ })
+}
+
+func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
+ t.Helper()
+ if _, err := db.Exec(query, args...); err != nil {
+ t.Fatalf("exec %q: %v", query, err)
+ }
+}