this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: use CAST(... AS CHAR) for MySQL search queries

MySQL doesn't support CAST(... AS TEXT), which caused 500 errors
on the /search endpoint in production. Use dialect detection to
select CHAR for MySQL and TEXT for SQLite.

Add data-layer search tests for both SQLite (default) and MySQL
(behind //go:build mysql tag) covering title, URL, tag, ordering,
and error preview exclusion logic.

+715 -9
+4 -1
Makefile
··· 3 3 BINARY_NAME=tumble 4 4 BUILD_DIR=bin 5 5 6 - .PHONY: all build clean test deps docs kill restart reset-db load-fixtures build-linux help fmt 6 + .PHONY: all build clean test test-mysql deps docs kill restart reset-db load-fixtures build-linux help fmt 7 7 8 8 all: build ## Build the binary (default) 9 9 ··· 33 33 34 34 test: ## Run unit tests 35 35 go test -v ./... 36 + 37 + test-mysql: ## Run MySQL-specific tests (requires MySQL) 38 + go test -v -tags mysql ./internal/data/ 36 39 37 40 test-api: build ## Run API tests 38 41 ./tests/run_integration_tests.sh
+3 -4
conf/config-test-mysql.yaml
··· 1 1 driver: mysql 2 2 database: tumble_test 3 3 username: tumble 4 - password: password 5 - host: localhost 6 - baseurl: http://localhost:8080 7 - port: "8080" 4 + host: 127.0.0.1:13306 5 + baseurl: http://localhost:8084 6 + port: "8084" 8 7 logging: 9 8 level: debug 10 9 output: stdout
+13 -4
internal/data/gorm_store.go
··· 116 116 // Exclude links with cached error previews using tiered TTLs: 117 117 // - Recent links (< 10 days old): error cache expires after 24h 118 118 // - Old links (>= 10 days old): error cache expires after 60 days 119 - // CAST(data AS TEXT) is required because glebarez/sqlite stores []byte 120 - // as BLOB, and SQLite's LIKE doesn't match text patterns against BLOBs. 119 + // CAST is required because glebarez/sqlite stores []byte as BLOB, 120 + // and SQLite's LIKE doesn't match text patterns against BLOBs. 121 + // MySQL doesn't support CAST(... AS TEXT), so use CHAR instead. 122 + castType := "TEXT" 123 + if s.db.Dialector.Name() == "mysql" { 124 + castType = "CHAR" 125 + } 121 126 recentCutoff := time.Now().Add(-24 * time.Hour) 122 127 oldCutoff := time.Now().Add(-60 * 24 * time.Hour) 123 128 linkAgeCutoff := time.Now().Add(-10 * 24 * time.Hour) ··· 125 130 Where(`(title LIKE ? OR url LIKE ? OR ircLinkID IN (SELECT resource_id FROM tags WHERE resource_type = 'link' AND tag LIKE ?)) 126 131 AND url NOT IN ( 127 132 SELECT lp.url FROM link_previews lp 128 - WHERE CAST(lp.data AS TEXT) LIKE '%"error":%' 133 + WHERE CAST(lp.data AS `+castType+`) LIKE '%"error":%' 129 134 AND ( 130 135 (EXISTS (SELECT 1 FROM ircLink il WHERE il.url = lp.url AND il.timestamp > ?) AND lp.updated_at > ?) 131 136 OR ··· 511 516 func (s *GormStore) GetUncheckedDeadLinkURLs(ctx context.Context) ([]string, error) { 512 517 var urls []string 513 518 // Find URLs with cached errors in link_previews that have no row in archive_lookups 519 + castType := "TEXT" 520 + if s.db.Dialector.Name() == "mysql" { 521 + castType = "CHAR" 522 + } 514 523 err := s.db.WithContext(ctx).Raw(` 515 524 SELECT DISTINCT lp.url FROM link_previews lp 516 - WHERE CAST(lp.data AS TEXT) LIKE '%"error":%' 525 + WHERE CAST(lp.data AS `+castType+`) LIKE '%"error":%' 517 526 AND lp.url NOT IN (SELECT al.url FROM archive_lookups al) 518 527 `).Scan(&urls).Error 519 528 return urls, err
+363
internal/data/search_mysql_test.go
··· 1 + //go:build mysql 2 + 3 + package data 4 + 5 + import ( 6 + "context" 7 + "os" 8 + "testing" 9 + "time" 10 + 11 + "tumble/internal/config" 12 + 13 + "gorm.io/driver/mysql" 14 + "gorm.io/gorm" 15 + ) 16 + 17 + // newMySQLTestStore creates a GormStore backed by a real MySQL instance. 18 + // Reads connection info from conf/config-test-mysql.yaml. 19 + // Set MYSQL_TEST_DSN to override the DSN entirely. 20 + func newMySQLTestStore(t *testing.T) *GormStore { 21 + t.Helper() 22 + dsn := os.Getenv("MYSQL_TEST_DSN") 23 + if dsn == "" { 24 + cfg, err := config.Load("../../conf/config-test-mysql.yaml") 25 + if err != nil { 26 + t.Fatalf("Failed to load MySQL test config: %v", err) 27 + } 28 + dsn = cfg.DSN() 29 + } 30 + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) 31 + if err != nil { 32 + t.Fatalf("Failed to connect to MySQL: %v", err) 33 + } 34 + store := NewGormStore(db) 35 + if err := store.Bootstrap(context.Background()); err != nil { 36 + t.Fatalf("Failed to bootstrap MySQL db: %v", err) 37 + } 38 + t.Cleanup(func() { 39 + db.Exec("DELETE FROM tags") 40 + db.Exec("DELETE FROM link_previews") 41 + db.Exec("DELETE FROM quote") 42 + db.Exec("DELETE FROM ircLink") 43 + }) 44 + return store 45 + } 46 + 47 + // --------------- SearchIRCLinks MySQL tests --------------- 48 + 49 + func TestSearchIRCLinks_ByTitle_MySQL(t *testing.T) { 50 + store := newMySQLTestStore(t) 51 + ctx := context.Background() 52 + 53 + _, err := store.InsertIRCLink(ctx, "alice", "Golang Tutorial", "http://example.com/go-mysql", "text/html") 54 + if err != nil { 55 + t.Fatalf("InsertIRCLink failed: %v", err) 56 + } 57 + _, err = store.InsertIRCLink(ctx, "bob", "Rust Guide", "http://example.com/rust-mysql", "text/html") 58 + if err != nil { 59 + t.Fatalf("InsertIRCLink failed: %v", err) 60 + } 61 + 62 + links, err := store.SearchIRCLinks(ctx, "Golang") 63 + if err != nil { 64 + t.Fatalf("SearchIRCLinks failed: %v", err) 65 + } 66 + if len(links) != 1 { 67 + t.Fatalf("expected 1 result, got %d", len(links)) 68 + } 69 + if links[0].Title != "Golang Tutorial" { 70 + t.Errorf("expected title 'Golang Tutorial', got %q", links[0].Title) 71 + } 72 + } 73 + 74 + func TestSearchIRCLinks_ByURL_MySQL(t *testing.T) { 75 + store := newMySQLTestStore(t) 76 + ctx := context.Background() 77 + 78 + _, err := store.InsertIRCLink(ctx, "alice", "Some Page", "http://example.com/unique-mysql-path", "text/html") 79 + if err != nil { 80 + t.Fatalf("InsertIRCLink failed: %v", err) 81 + } 82 + 83 + links, err := store.SearchIRCLinks(ctx, "unique-mysql-path") 84 + if err != nil { 85 + t.Fatalf("SearchIRCLinks failed: %v", err) 86 + } 87 + if len(links) != 1 { 88 + t.Fatalf("expected 1 result, got %d", len(links)) 89 + } 90 + if links[0].URL != "http://example.com/unique-mysql-path" { 91 + t.Errorf("expected URL with 'unique-mysql-path', got %q", links[0].URL) 92 + } 93 + } 94 + 95 + func TestSearchIRCLinks_ByTag_MySQL(t *testing.T) { 96 + store := newMySQLTestStore(t) 97 + ctx := context.Background() 98 + 99 + id, err := store.InsertIRCLink(ctx, "alice", "Tagged Link", "http://example.com/tagged-mysql", "text/html") 100 + if err != nil { 101 + t.Fatalf("InsertIRCLink failed: %v", err) 102 + } 103 + 104 + _, err = store.CreateTag(ctx, Tag{ 105 + Tag: "special-mysql-topic", 106 + ResourceType: "link", 107 + ResourceID: id, 108 + CreatedBy: "alice", 109 + }) 110 + if err != nil { 111 + t.Fatalf("CreateTag failed: %v", err) 112 + } 113 + 114 + links, err := store.SearchIRCLinks(ctx, "special-mysql-topic") 115 + if err != nil { 116 + t.Fatalf("SearchIRCLinks failed: %v", err) 117 + } 118 + if len(links) != 1 { 119 + t.Fatalf("expected 1 result, got %d", len(links)) 120 + } 121 + if links[0].Title != "Tagged Link" { 122 + t.Errorf("expected 'Tagged Link', got %q", links[0].Title) 123 + } 124 + } 125 + 126 + func TestSearchIRCLinks_NoMatch_MySQL(t *testing.T) { 127 + store := newMySQLTestStore(t) 128 + ctx := context.Background() 129 + 130 + _, err := store.InsertIRCLink(ctx, "alice", "Something", "http://example.com/a-mysql", "text/html") 131 + if err != nil { 132 + t.Fatalf("InsertIRCLink failed: %v", err) 133 + } 134 + 135 + links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy-mysql") 136 + if err != nil { 137 + t.Fatalf("SearchIRCLinks failed: %v", err) 138 + } 139 + if len(links) != 0 { 140 + t.Errorf("expected 0 results, got %d", len(links)) 141 + } 142 + } 143 + 144 + func TestSearchIRCLinks_OrderedByClicks_MySQL(t *testing.T) { 145 + store := newMySQLTestStore(t) 146 + ctx := context.Background() 147 + db := store.db 148 + 149 + _, err := store.InsertIRCLink(ctx, "alice", "SearchM Low", "http://example.com/searchm-low", "text/html") 150 + if err != nil { 151 + t.Fatalf("InsertIRCLink failed: %v", err) 152 + } 153 + _, err = store.InsertIRCLink(ctx, "bob", "SearchM High", "http://example.com/searchm-high", "text/html") 154 + if err != nil { 155 + t.Fatalf("InsertIRCLink failed: %v", err) 156 + } 157 + 158 + db.Model(&IRCLink{}).Where("title = ?", "SearchM Low").Update("clicks", 5) 159 + db.Model(&IRCLink{}).Where("title = ?", "SearchM High").Update("clicks", 50) 160 + 161 + links, err := store.SearchIRCLinks(ctx, "SearchM") 162 + if err != nil { 163 + t.Fatalf("SearchIRCLinks failed: %v", err) 164 + } 165 + if len(links) != 2 { 166 + t.Fatalf("expected 2 results, got %d", len(links)) 167 + } 168 + if links[0].Title != "SearchM High" { 169 + t.Errorf("expected first result 'SearchM High' (most clicks), got %q", links[0].Title) 170 + } 171 + if links[1].Title != "SearchM Low" { 172 + t.Errorf("expected second result 'SearchM Low', got %q", links[1].Title) 173 + } 174 + } 175 + 176 + func TestSearchIRCLinks_ExcludesErrorPreviews_MySQL(t *testing.T) { 177 + store := newMySQLTestStore(t) 178 + ctx := context.Background() 179 + 180 + _, err := store.InsertIRCLink(ctx, "alice", "Good Link", "http://example.com/good-mysql", "text/html") 181 + if err != nil { 182 + t.Fatalf("InsertIRCLink failed: %v", err) 183 + } 184 + _, err = store.InsertIRCLink(ctx, "bob", "Bad Link", "http://example.com/bad-mysql", "text/html") 185 + if err != nil { 186 + t.Fatalf("InsertIRCLink failed: %v", err) 187 + } 188 + 189 + errorData := []byte(`{"error":"status 404"}`) 190 + if err := store.InsertLinkPreview(ctx, "http://example.com/bad-mysql", errorData); err != nil { 191 + t.Fatalf("InsertLinkPreview failed: %v", err) 192 + } 193 + 194 + links, err := store.SearchIRCLinks(ctx, "Link") 195 + if err != nil { 196 + t.Fatalf("SearchIRCLinks failed: %v", err) 197 + } 198 + if len(links) != 1 { 199 + t.Fatalf("expected 1 result (error link excluded), got %d", len(links)) 200 + } 201 + if links[0].Title != "Good Link" { 202 + t.Errorf("expected 'Good Link', got %q", links[0].Title) 203 + } 204 + } 205 + 206 + func TestSearchIRCLinks_ExpiredErrorCacheIncluded_MySQL(t *testing.T) { 207 + store := newMySQLTestStore(t) 208 + ctx := context.Background() 209 + db := store.db 210 + 211 + _, err := store.InsertIRCLink(ctx, "alice", "Recoverable Link", "http://example.com/recover-mysql", "text/html") 212 + if err != nil { 213 + t.Fatalf("InsertIRCLink failed: %v", err) 214 + } 215 + 216 + errorData := []byte(`{"error":"status 503"}`) 217 + if err := store.InsertLinkPreview(ctx, "http://example.com/recover-mysql", errorData); err != nil { 218 + t.Fatalf("InsertLinkPreview failed: %v", err) 219 + } 220 + twoDaysAgo := time.Now().Add(-48 * time.Hour) 221 + db.Model(&LinkPreview{}).Where("url = ?", "http://example.com/recover-mysql").Update("updated_at", twoDaysAgo) 222 + 223 + links, err := store.SearchIRCLinks(ctx, "Recoverable") 224 + if err != nil { 225 + t.Fatalf("SearchIRCLinks failed: %v", err) 226 + } 227 + if len(links) != 1 { 228 + t.Fatalf("expected 1 result (expired error cache should be included), got %d", len(links)) 229 + } 230 + if links[0].Title != "Recoverable Link" { 231 + t.Errorf("expected 'Recoverable Link', got %q", links[0].Title) 232 + } 233 + } 234 + 235 + // --------------- SearchQuotes MySQL tests --------------- 236 + 237 + func TestSearchQuotes_ByQuoteText_MySQL(t *testing.T) { 238 + store := newMySQLTestStore(t) 239 + ctx := context.Background() 240 + 241 + _, err := store.InsertQuote(ctx, "To be or not to be", "Shakespeare", "alice") 242 + if err != nil { 243 + t.Fatalf("InsertQuote failed: %v", err) 244 + } 245 + _, err = store.InsertQuote(ctx, "I think therefore I am", "Descartes", "bob") 246 + if err != nil { 247 + t.Fatalf("InsertQuote failed: %v", err) 248 + } 249 + 250 + quotes, err := store.SearchQuotes(ctx, "not to be") 251 + if err != nil { 252 + t.Fatalf("SearchQuotes failed: %v", err) 253 + } 254 + if len(quotes) != 1 { 255 + t.Fatalf("expected 1 result, got %d", len(quotes)) 256 + } 257 + if quotes[0].Author != "Shakespeare" { 258 + t.Errorf("expected author 'Shakespeare', got %q", quotes[0].Author) 259 + } 260 + } 261 + 262 + func TestSearchQuotes_ByAuthor_MySQL(t *testing.T) { 263 + store := newMySQLTestStore(t) 264 + ctx := context.Background() 265 + 266 + _, err := store.InsertQuote(ctx, "Some quote", "UniqueAuthor42", "alice") 267 + if err != nil { 268 + t.Fatalf("InsertQuote failed: %v", err) 269 + } 270 + 271 + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42") 272 + if err != nil { 273 + t.Fatalf("SearchQuotes failed: %v", err) 274 + } 275 + if len(quotes) != 1 { 276 + t.Fatalf("expected 1 result, got %d", len(quotes)) 277 + } 278 + if quotes[0].Quote != "Some quote" { 279 + t.Errorf("expected quote 'Some quote', got %q", quotes[0].Quote) 280 + } 281 + } 282 + 283 + func TestSearchQuotes_ByTag_MySQL(t *testing.T) { 284 + store := newMySQLTestStore(t) 285 + ctx := context.Background() 286 + 287 + id, err := store.InsertQuote(ctx, "A tagged quote", "someone", "alice") 288 + if err != nil { 289 + t.Fatalf("InsertQuote failed: %v", err) 290 + } 291 + 292 + _, err = store.CreateTag(ctx, Tag{ 293 + Tag: "philosophy", 294 + ResourceType: "quote", 295 + ResourceID: id, 296 + CreatedBy: "alice", 297 + }) 298 + if err != nil { 299 + t.Fatalf("CreateTag failed: %v", err) 300 + } 301 + 302 + quotes, err := store.SearchQuotes(ctx, "philosophy") 303 + if err != nil { 304 + t.Fatalf("SearchQuotes failed: %v", err) 305 + } 306 + if len(quotes) != 1 { 307 + t.Fatalf("expected 1 result, got %d", len(quotes)) 308 + } 309 + if quotes[0].Quote != "A tagged quote" { 310 + t.Errorf("expected 'A tagged quote', got %q", quotes[0].Quote) 311 + } 312 + } 313 + 314 + func TestSearchQuotes_NoMatch_MySQL(t *testing.T) { 315 + store := newMySQLTestStore(t) 316 + ctx := context.Background() 317 + 318 + _, err := store.InsertQuote(ctx, "Hello world", "author1", "poster1") 319 + if err != nil { 320 + t.Fatalf("InsertQuote failed: %v", err) 321 + } 322 + 323 + quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy-mysql") 324 + if err != nil { 325 + t.Fatalf("SearchQuotes failed: %v", err) 326 + } 327 + if len(quotes) != 0 { 328 + t.Errorf("expected 0 results, got %d", len(quotes)) 329 + } 330 + } 331 + 332 + func TestSearchQuotes_OrderedByTimestamp_MySQL(t *testing.T) { 333 + store := newMySQLTestStore(t) 334 + ctx := context.Background() 335 + db := store.db 336 + 337 + _, err := store.InsertQuote(ctx, "SearchM older quote", "auth1", "poster") 338 + if err != nil { 339 + t.Fatalf("InsertQuote failed: %v", err) 340 + } 341 + _, err = store.InsertQuote(ctx, "SearchM newer quote", "auth2", "poster") 342 + if err != nil { 343 + t.Fatalf("InsertQuote failed: %v", err) 344 + } 345 + 346 + now := time.Now() 347 + db.Model(&Quote{}).Where("quote = ?", "SearchM older quote").Update("timestamp", now.Add(-48*time.Hour)) 348 + db.Model(&Quote{}).Where("quote = ?", "SearchM newer quote").Update("timestamp", now.Add(-1*time.Hour)) 349 + 350 + quotes, err := store.SearchQuotes(ctx, "SearchM") 351 + if err != nil { 352 + t.Fatalf("SearchQuotes failed: %v", err) 353 + } 354 + if len(quotes) != 2 { 355 + t.Fatalf("expected 2 results, got %d", len(quotes)) 356 + } 357 + if quotes[0].Quote != "SearchM newer quote" { 358 + t.Errorf("expected first result 'SearchM newer quote' (most recent), got %q", quotes[0].Quote) 359 + } 360 + if quotes[1].Quote != "SearchM older quote" { 361 + t.Errorf("expected second result 'SearchM older quote', got %q", quotes[1].Quote) 362 + } 363 + }
+332
internal/data/search_test.go
··· 1 + package data 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + // --------------- SearchIRCLinks tests --------------- 10 + 11 + func TestSearchIRCLinks_ByTitle(t *testing.T) { 12 + store := newTestStore(t) 13 + ctx := context.Background() 14 + 15 + _, err := store.InsertIRCLink(ctx, "alice", "Golang Tutorial", "http://example.com/go", "text/html") 16 + if err != nil { 17 + t.Fatalf("InsertIRCLink failed: %v", err) 18 + } 19 + _, err = store.InsertIRCLink(ctx, "bob", "Rust Guide", "http://example.com/rust", "text/html") 20 + if err != nil { 21 + t.Fatalf("InsertIRCLink failed: %v", err) 22 + } 23 + 24 + links, err := store.SearchIRCLinks(ctx, "Golang") 25 + if err != nil { 26 + t.Fatalf("SearchIRCLinks failed: %v", err) 27 + } 28 + if len(links) != 1 { 29 + t.Fatalf("expected 1 result, got %d", len(links)) 30 + } 31 + if links[0].Title != "Golang Tutorial" { 32 + t.Errorf("expected title 'Golang Tutorial', got %q", links[0].Title) 33 + } 34 + } 35 + 36 + func TestSearchIRCLinks_ByURL(t *testing.T) { 37 + store := newTestStore(t) 38 + ctx := context.Background() 39 + 40 + _, err := store.InsertIRCLink(ctx, "alice", "Some Page", "http://example.com/unique-path", "text/html") 41 + if err != nil { 42 + t.Fatalf("InsertIRCLink failed: %v", err) 43 + } 44 + 45 + links, err := store.SearchIRCLinks(ctx, "unique-path") 46 + if err != nil { 47 + t.Fatalf("SearchIRCLinks failed: %v", err) 48 + } 49 + if len(links) != 1 { 50 + t.Fatalf("expected 1 result, got %d", len(links)) 51 + } 52 + if links[0].URL != "http://example.com/unique-path" { 53 + t.Errorf("expected URL with 'unique-path', got %q", links[0].URL) 54 + } 55 + } 56 + 57 + func TestSearchIRCLinks_ByTag(t *testing.T) { 58 + store := newTestStore(t) 59 + ctx := context.Background() 60 + 61 + id, err := store.InsertIRCLink(ctx, "alice", "Tagged Link", "http://example.com/tagged", "text/html") 62 + if err != nil { 63 + t.Fatalf("InsertIRCLink failed: %v", err) 64 + } 65 + 66 + _, err = store.CreateTag(ctx, Tag{ 67 + Tag: "special-topic", 68 + ResourceType: "link", 69 + ResourceID: id, 70 + CreatedBy: "alice", 71 + }) 72 + if err != nil { 73 + t.Fatalf("CreateTag failed: %v", err) 74 + } 75 + 76 + links, err := store.SearchIRCLinks(ctx, "special-topic") 77 + if err != nil { 78 + t.Fatalf("SearchIRCLinks failed: %v", err) 79 + } 80 + if len(links) != 1 { 81 + t.Fatalf("expected 1 result, got %d", len(links)) 82 + } 83 + if links[0].Title != "Tagged Link" { 84 + t.Errorf("expected 'Tagged Link', got %q", links[0].Title) 85 + } 86 + } 87 + 88 + func TestSearchIRCLinks_NoMatch(t *testing.T) { 89 + store := newTestStore(t) 90 + ctx := context.Background() 91 + 92 + _, err := store.InsertIRCLink(ctx, "alice", "Something", "http://example.com/a", "text/html") 93 + if err != nil { 94 + t.Fatalf("InsertIRCLink failed: %v", err) 95 + } 96 + 97 + links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy") 98 + if err != nil { 99 + t.Fatalf("SearchIRCLinks failed: %v", err) 100 + } 101 + if len(links) != 0 { 102 + t.Errorf("expected 0 results, got %d", len(links)) 103 + } 104 + } 105 + 106 + func TestSearchIRCLinks_OrderedByClicks(t *testing.T) { 107 + store := newTestStore(t) 108 + ctx := context.Background() 109 + db := store.db 110 + 111 + _, err := store.InsertIRCLink(ctx, "alice", "Search Low", "http://example.com/search-low", "text/html") 112 + if err != nil { 113 + t.Fatalf("InsertIRCLink failed: %v", err) 114 + } 115 + _, err = store.InsertIRCLink(ctx, "bob", "Search High", "http://example.com/search-high", "text/html") 116 + if err != nil { 117 + t.Fatalf("InsertIRCLink failed: %v", err) 118 + } 119 + 120 + // Set different click counts 121 + db.Model(&IRCLink{}).Where("title = ?", "Search Low").Update("clicks", 5) 122 + db.Model(&IRCLink{}).Where("title = ?", "Search High").Update("clicks", 50) 123 + 124 + links, err := store.SearchIRCLinks(ctx, "Search") 125 + if err != nil { 126 + t.Fatalf("SearchIRCLinks failed: %v", err) 127 + } 128 + if len(links) != 2 { 129 + t.Fatalf("expected 2 results, got %d", len(links)) 130 + } 131 + if links[0].Title != "Search High" { 132 + t.Errorf("expected first result 'Search High' (most clicks), got %q", links[0].Title) 133 + } 134 + if links[1].Title != "Search Low" { 135 + t.Errorf("expected second result 'Search Low', got %q", links[1].Title) 136 + } 137 + } 138 + 139 + func TestSearchIRCLinks_ExcludesErrorPreviews(t *testing.T) { 140 + store := newTestStore(t) 141 + ctx := context.Background() 142 + 143 + // Insert two links — one will have an error preview, one won't 144 + _, err := store.InsertIRCLink(ctx, "alice", "Good Link", "http://example.com/good", "text/html") 145 + if err != nil { 146 + t.Fatalf("InsertIRCLink failed: %v", err) 147 + } 148 + _, err = store.InsertIRCLink(ctx, "bob", "Bad Link", "http://example.com/bad", "text/html") 149 + if err != nil { 150 + t.Fatalf("InsertIRCLink failed: %v", err) 151 + } 152 + 153 + // Insert an error preview for the bad link (recent, so it's within the cache TTL) 154 + errorData := []byte(`{"error":"status 404"}`) 155 + if err := store.InsertLinkPreview(ctx, "http://example.com/bad", errorData); err != nil { 156 + t.Fatalf("InsertLinkPreview failed: %v", err) 157 + } 158 + 159 + links, err := store.SearchIRCLinks(ctx, "Link") 160 + if err != nil { 161 + t.Fatalf("SearchIRCLinks failed: %v", err) 162 + } 163 + if len(links) != 1 { 164 + t.Fatalf("expected 1 result (error link excluded), got %d", len(links)) 165 + } 166 + if links[0].Title != "Good Link" { 167 + t.Errorf("expected 'Good Link', got %q", links[0].Title) 168 + } 169 + } 170 + 171 + func TestSearchIRCLinks_ExpiredErrorCacheIncluded(t *testing.T) { 172 + store := newTestStore(t) 173 + ctx := context.Background() 174 + db := store.db 175 + 176 + // Insert link that is "recent" (< 10 days old) — its error cache TTL is 24h 177 + _, err := store.InsertIRCLink(ctx, "alice", "Recoverable Link", "http://example.com/recover", "text/html") 178 + if err != nil { 179 + t.Fatalf("InsertIRCLink failed: %v", err) 180 + } 181 + 182 + // Insert an error preview and backdate its updated_at to >24h ago 183 + errorData := []byte(`{"error":"status 503"}`) 184 + if err := store.InsertLinkPreview(ctx, "http://example.com/recover", errorData); err != nil { 185 + t.Fatalf("InsertLinkPreview failed: %v", err) 186 + } 187 + // Backdate the preview's updated_at to 2 days ago (beyond the 24h TTL for recent links) 188 + twoDaysAgo := time.Now().Add(-48 * time.Hour) 189 + db.Model(&LinkPreview{}).Where("url = ?", "http://example.com/recover").Update("updated_at", twoDaysAgo) 190 + 191 + links, err := store.SearchIRCLinks(ctx, "Recoverable") 192 + if err != nil { 193 + t.Fatalf("SearchIRCLinks failed: %v", err) 194 + } 195 + if len(links) != 1 { 196 + t.Fatalf("expected 1 result (expired error cache should be included), got %d", len(links)) 197 + } 198 + if links[0].Title != "Recoverable Link" { 199 + t.Errorf("expected 'Recoverable Link', got %q", links[0].Title) 200 + } 201 + } 202 + 203 + // --------------- SearchQuotes tests --------------- 204 + 205 + func TestSearchQuotes_ByQuoteText(t *testing.T) { 206 + store := newTestStore(t) 207 + ctx := context.Background() 208 + 209 + _, err := store.InsertQuote(ctx, "To be or not to be", "Shakespeare", "alice") 210 + if err != nil { 211 + t.Fatalf("InsertQuote failed: %v", err) 212 + } 213 + _, err = store.InsertQuote(ctx, "I think therefore I am", "Descartes", "bob") 214 + if err != nil { 215 + t.Fatalf("InsertQuote failed: %v", err) 216 + } 217 + 218 + quotes, err := store.SearchQuotes(ctx, "not to be") 219 + if err != nil { 220 + t.Fatalf("SearchQuotes failed: %v", err) 221 + } 222 + if len(quotes) != 1 { 223 + t.Fatalf("expected 1 result, got %d", len(quotes)) 224 + } 225 + if quotes[0].Author != "Shakespeare" { 226 + t.Errorf("expected author 'Shakespeare', got %q", quotes[0].Author) 227 + } 228 + } 229 + 230 + func TestSearchQuotes_ByAuthor(t *testing.T) { 231 + store := newTestStore(t) 232 + ctx := context.Background() 233 + 234 + _, err := store.InsertQuote(ctx, "Some quote", "UniqueAuthor42", "alice") 235 + if err != nil { 236 + t.Fatalf("InsertQuote failed: %v", err) 237 + } 238 + 239 + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42") 240 + if err != nil { 241 + t.Fatalf("SearchQuotes failed: %v", err) 242 + } 243 + if len(quotes) != 1 { 244 + t.Fatalf("expected 1 result, got %d", len(quotes)) 245 + } 246 + if quotes[0].Quote != "Some quote" { 247 + t.Errorf("expected quote 'Some quote', got %q", quotes[0].Quote) 248 + } 249 + } 250 + 251 + func TestSearchQuotes_ByTag(t *testing.T) { 252 + store := newTestStore(t) 253 + ctx := context.Background() 254 + 255 + id, err := store.InsertQuote(ctx, "A tagged quote", "someone", "alice") 256 + if err != nil { 257 + t.Fatalf("InsertQuote failed: %v", err) 258 + } 259 + 260 + _, err = store.CreateTag(ctx, Tag{ 261 + Tag: "philosophy", 262 + ResourceType: "quote", 263 + ResourceID: id, 264 + CreatedBy: "alice", 265 + }) 266 + if err != nil { 267 + t.Fatalf("CreateTag failed: %v", err) 268 + } 269 + 270 + quotes, err := store.SearchQuotes(ctx, "philosophy") 271 + if err != nil { 272 + t.Fatalf("SearchQuotes failed: %v", err) 273 + } 274 + if len(quotes) != 1 { 275 + t.Fatalf("expected 1 result, got %d", len(quotes)) 276 + } 277 + if quotes[0].Quote != "A tagged quote" { 278 + t.Errorf("expected 'A tagged quote', got %q", quotes[0].Quote) 279 + } 280 + } 281 + 282 + func TestSearchQuotes_NoMatch(t *testing.T) { 283 + store := newTestStore(t) 284 + ctx := context.Background() 285 + 286 + _, err := store.InsertQuote(ctx, "Hello world", "author1", "poster1") 287 + if err != nil { 288 + t.Fatalf("InsertQuote failed: %v", err) 289 + } 290 + 291 + quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy") 292 + if err != nil { 293 + t.Fatalf("SearchQuotes failed: %v", err) 294 + } 295 + if len(quotes) != 0 { 296 + t.Errorf("expected 0 results, got %d", len(quotes)) 297 + } 298 + } 299 + 300 + func TestSearchQuotes_OrderedByTimestamp(t *testing.T) { 301 + store := newTestStore(t) 302 + ctx := context.Background() 303 + db := store.db 304 + 305 + _, err := store.InsertQuote(ctx, "Search older quote", "auth1", "poster") 306 + if err != nil { 307 + t.Fatalf("InsertQuote failed: %v", err) 308 + } 309 + _, err = store.InsertQuote(ctx, "Search newer quote", "auth2", "poster") 310 + if err != nil { 311 + t.Fatalf("InsertQuote failed: %v", err) 312 + } 313 + 314 + // Set timestamps: newer quote gets a more recent time 315 + now := time.Now() 316 + db.Model(&Quote{}).Where("quote = ?", "Search older quote").Update("timestamp", now.Add(-48*time.Hour)) 317 + db.Model(&Quote{}).Where("quote = ?", "Search newer quote").Update("timestamp", now.Add(-1*time.Hour)) 318 + 319 + quotes, err := store.SearchQuotes(ctx, "Search") 320 + if err != nil { 321 + t.Fatalf("SearchQuotes failed: %v", err) 322 + } 323 + if len(quotes) != 2 { 324 + t.Fatalf("expected 2 results, got %d", len(quotes)) 325 + } 326 + if quotes[0].Quote != "Search newer quote" { 327 + t.Errorf("expected first result 'Search newer quote' (most recent), got %q", quotes[0].Quote) 328 + } 329 + if quotes[1].Quote != "Search older quote" { 330 + t.Errorf("expected second result 'Search older quote', got %q", quotes[1].Quote) 331 + } 332 + }