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