summaryrefslogtreecommitdiff
path: root/internal/store/shopping_import_test.go
blob: 0e246d813d66a69310044f03afc87c72e85f50ed (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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)
	}
}