summaryrefslogtreecommitdiff
path: root/internal/store/shopping_import_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store/shopping_import_test.go')
-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)
+ }
+}