diff --git a/Makefile b/Makefile index 65bc650..347d15c 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VERSION=$(shell git describe --tags --always | sed -e 's/-/\./g') BINARY_NAME=tumble BUILD_DIR=bin -.PHONY: all build clean test deps docs kill restart reset-db load-fixtures build-linux help fmt +.PHONY: all build clean test test-mysql deps docs kill restart reset-db load-fixtures build-linux help fmt all: build ## Build the binary (default) @@ -34,6 +34,9 @@ clean: ## Clean build directory test: ## Run unit tests go test -v ./... +test-mysql: ## Run MySQL-specific tests (requires MySQL) + go test -v -tags mysql ./internal/data/ + test-api: build ## Run API tests ./tests/run_integration_tests.sh diff --git a/conf/config-test-mysql.yaml b/conf/config-test-mysql.yaml index d394c59..0993105 100644 --- a/conf/config-test-mysql.yaml +++ b/conf/config-test-mysql.yaml @@ -1,10 +1,9 @@ driver: mysql database: tumble_test username: tumble -password: password -host: localhost -baseurl: http://localhost:8080 -port: "8080" +host: 127.0.0.1:13306 +baseurl: http://localhost:8084 +port: "8084" logging: level: debug output: stdout diff --git a/internal/data/gorm_store.go b/internal/data/gorm_store.go index 39b6635..86ab223 100644 --- a/internal/data/gorm_store.go +++ b/internal/data/gorm_store.go @@ -116,8 +116,13 @@ func (s *GormStore) SearchIRCLinks(ctx context.Context, query string) ([]IRCLink // Exclude links with cached error previews using tiered TTLs: // - Recent links (< 10 days old): error cache expires after 24h // - Old links (>= 10 days old): error cache expires after 60 days - // CAST(data AS TEXT) is required because glebarez/sqlite stores []byte - // as BLOB, and SQLite's LIKE doesn't match text patterns against BLOBs. + // CAST is required because glebarez/sqlite stores []byte as BLOB, + // and SQLite's LIKE doesn't match text patterns against BLOBs. + // MySQL doesn't support CAST(... AS TEXT), so use CHAR instead. + castType := "TEXT" + if s.db.Dialector.Name() == "mysql" { + castType = "CHAR" + } recentCutoff := time.Now().Add(-24 * time.Hour) oldCutoff := time.Now().Add(-60 * 24 * time.Hour) linkAgeCutoff := time.Now().Add(-10 * 24 * time.Hour) @@ -125,7 +130,7 @@ func (s *GormStore) SearchIRCLinks(ctx context.Context, query string) ([]IRCLink Where(`(title LIKE ? OR url LIKE ? OR ircLinkID IN (SELECT resource_id FROM tags WHERE resource_type = 'link' AND tag LIKE ?)) AND url NOT IN ( SELECT lp.url FROM link_previews lp - WHERE CAST(lp.data AS TEXT) LIKE '%"error":%' + WHERE CAST(lp.data AS `+castType+`) LIKE '%"error":%' AND ( (EXISTS (SELECT 1 FROM ircLink il WHERE il.url = lp.url AND il.timestamp > ?) AND lp.updated_at > ?) OR @@ -511,9 +516,13 @@ func (s *GormStore) UpsertArchiveLookup(ctx context.Context, lookup *ArchiveLook func (s *GormStore) GetUncheckedDeadLinkURLs(ctx context.Context) ([]string, error) { var urls []string // Find URLs with cached errors in link_previews that have no row in archive_lookups + castType := "TEXT" + if s.db.Dialector.Name() == "mysql" { + castType = "CHAR" + } err := s.db.WithContext(ctx).Raw(` SELECT DISTINCT lp.url FROM link_previews lp - WHERE CAST(lp.data AS TEXT) LIKE '%"error":%' + WHERE CAST(lp.data AS `+castType+`) LIKE '%"error":%' AND lp.url NOT IN (SELECT al.url FROM archive_lookups al) `).Scan(&urls).Error return urls, err diff --git a/internal/data/search_mysql_test.go b/internal/data/search_mysql_test.go new file mode 100644 index 0000000..4c4b8fc --- /dev/null +++ b/internal/data/search_mysql_test.go @@ -0,0 +1,363 @@ +//go:build mysql + +package data + +import ( + "context" + "os" + "testing" + "time" + + "tumble/internal/config" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// newMySQLTestStore creates a GormStore backed by a real MySQL instance. +// Reads connection info from conf/config-test-mysql.yaml. +// Set MYSQL_TEST_DSN to override the DSN entirely. +func newMySQLTestStore(t *testing.T) *GormStore { + t.Helper() + dsn := os.Getenv("MYSQL_TEST_DSN") + if dsn == "" { + cfg, err := config.Load("../../conf/config-test-mysql.yaml") + if err != nil { + t.Fatalf("Failed to load MySQL test config: %v", err) + } + dsn = cfg.DSN() + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to connect to MySQL: %v", err) + } + store := NewGormStore(db) + if err := store.Bootstrap(context.Background()); err != nil { + t.Fatalf("Failed to bootstrap MySQL db: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM tags") + db.Exec("DELETE FROM link_previews") + db.Exec("DELETE FROM quote") + db.Exec("DELETE FROM ircLink") + }) + return store +} + +// --------------- SearchIRCLinks MySQL tests --------------- + +func TestSearchIRCLinks_ByTitle_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + + _, err := store.InsertIRCLink(ctx, "alice", "Golang Tutorial", "http://example.com/go-mysql", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + _, err = store.InsertIRCLink(ctx, "bob", "Rust Guide", "http://example.com/rust-mysql", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "Golang") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result, got %d", len(links)) + } + if links[0].Title != "Golang Tutorial" { + t.Errorf("expected title 'Golang Tutorial', got %q", links[0].Title) + } +} + +func TestSearchIRCLinks_ByURL_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + + _, err := store.InsertIRCLink(ctx, "alice", "Some Page", "http://example.com/unique-mysql-path", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "unique-mysql-path") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result, got %d", len(links)) + } + if links[0].URL != "http://example.com/unique-mysql-path" { + t.Errorf("expected URL with 'unique-mysql-path', got %q", links[0].URL) + } +} + +func TestSearchIRCLinks_ByTag_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + + id, err := store.InsertIRCLink(ctx, "alice", "Tagged Link", "http://example.com/tagged-mysql", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + _, err = store.CreateTag(ctx, Tag{ + Tag: "special-mysql-topic", + ResourceType: "link", + ResourceID: id, + CreatedBy: "alice", + }) + if err != nil { + t.Fatalf("CreateTag failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "special-mysql-topic") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result, got %d", len(links)) + } + if links[0].Title != "Tagged Link" { + t.Errorf("expected 'Tagged Link', got %q", links[0].Title) + } +} + +func TestSearchIRCLinks_NoMatch_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + + _, err := store.InsertIRCLink(ctx, "alice", "Something", "http://example.com/a-mysql", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy-mysql") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 0 { + t.Errorf("expected 0 results, got %d", len(links)) + } +} + +func TestSearchIRCLinks_OrderedByClicks_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + db := store.db + + _, err := store.InsertIRCLink(ctx, "alice", "SearchM Low", "http://example.com/searchm-low", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + _, err = store.InsertIRCLink(ctx, "bob", "SearchM High", "http://example.com/searchm-high", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + db.Model(&IRCLink{}).Where("title = ?", "SearchM Low").Update("clicks", 5) + db.Model(&IRCLink{}).Where("title = ?", "SearchM High").Update("clicks", 50) + + links, err := store.SearchIRCLinks(ctx, "SearchM") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 2 { + t.Fatalf("expected 2 results, got %d", len(links)) + } + if links[0].Title != "SearchM High" { + t.Errorf("expected first result 'SearchM High' (most clicks), got %q", links[0].Title) + } + if links[1].Title != "SearchM Low" { + t.Errorf("expected second result 'SearchM Low', got %q", links[1].Title) + } +} + +func TestSearchIRCLinks_ExcludesErrorPreviews_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + + _, err := store.InsertIRCLink(ctx, "alice", "Good Link", "http://example.com/good-mysql", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + _, err = store.InsertIRCLink(ctx, "bob", "Bad Link", "http://example.com/bad-mysql", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + errorData := []byte(`{"error":"status 404"}`) + if err := store.InsertLinkPreview(ctx, "http://example.com/bad-mysql", errorData); err != nil { + t.Fatalf("InsertLinkPreview failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "Link") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result (error link excluded), got %d", len(links)) + } + if links[0].Title != "Good Link" { + t.Errorf("expected 'Good Link', got %q", links[0].Title) + } +} + +func TestSearchIRCLinks_ExpiredErrorCacheIncluded_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + db := store.db + + _, err := store.InsertIRCLink(ctx, "alice", "Recoverable Link", "http://example.com/recover-mysql", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + errorData := []byte(`{"error":"status 503"}`) + if err := store.InsertLinkPreview(ctx, "http://example.com/recover-mysql", errorData); err != nil { + t.Fatalf("InsertLinkPreview failed: %v", err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + db.Model(&LinkPreview{}).Where("url = ?", "http://example.com/recover-mysql").Update("updated_at", twoDaysAgo) + + links, err := store.SearchIRCLinks(ctx, "Recoverable") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result (expired error cache should be included), got %d", len(links)) + } + if links[0].Title != "Recoverable Link" { + t.Errorf("expected 'Recoverable Link', got %q", links[0].Title) + } +} + +// --------------- SearchQuotes MySQL tests --------------- + +func TestSearchQuotes_ByQuoteText_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + + _, err := store.InsertQuote(ctx, "To be or not to be", "Shakespeare", "alice") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + _, err = store.InsertQuote(ctx, "I think therefore I am", "Descartes", "bob") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + quotes, err := store.SearchQuotes(ctx, "not to be") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 1 { + t.Fatalf("expected 1 result, got %d", len(quotes)) + } + if quotes[0].Author != "Shakespeare" { + t.Errorf("expected author 'Shakespeare', got %q", quotes[0].Author) + } +} + +func TestSearchQuotes_ByAuthor_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + + _, err := store.InsertQuote(ctx, "Some quote", "UniqueAuthor42", "alice") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 1 { + t.Fatalf("expected 1 result, got %d", len(quotes)) + } + if quotes[0].Quote != "Some quote" { + t.Errorf("expected quote 'Some quote', got %q", quotes[0].Quote) + } +} + +func TestSearchQuotes_ByTag_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + + id, err := store.InsertQuote(ctx, "A tagged quote", "someone", "alice") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + _, err = store.CreateTag(ctx, Tag{ + Tag: "philosophy", + ResourceType: "quote", + ResourceID: id, + CreatedBy: "alice", + }) + if err != nil { + t.Fatalf("CreateTag failed: %v", err) + } + + quotes, err := store.SearchQuotes(ctx, "philosophy") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 1 { + t.Fatalf("expected 1 result, got %d", len(quotes)) + } + if quotes[0].Quote != "A tagged quote" { + t.Errorf("expected 'A tagged quote', got %q", quotes[0].Quote) + } +} + +func TestSearchQuotes_NoMatch_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + + _, err := store.InsertQuote(ctx, "Hello world", "author1", "poster1") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy-mysql") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 0 { + t.Errorf("expected 0 results, got %d", len(quotes)) + } +} + +func TestSearchQuotes_OrderedByTimestamp_MySQL(t *testing.T) { + store := newMySQLTestStore(t) + ctx := context.Background() + db := store.db + + _, err := store.InsertQuote(ctx, "SearchM older quote", "auth1", "poster") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + _, err = store.InsertQuote(ctx, "SearchM newer quote", "auth2", "poster") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + now := time.Now() + db.Model(&Quote{}).Where("quote = ?", "SearchM older quote").Update("timestamp", now.Add(-48*time.Hour)) + db.Model(&Quote{}).Where("quote = ?", "SearchM newer quote").Update("timestamp", now.Add(-1*time.Hour)) + + quotes, err := store.SearchQuotes(ctx, "SearchM") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 2 { + t.Fatalf("expected 2 results, got %d", len(quotes)) + } + if quotes[0].Quote != "SearchM newer quote" { + t.Errorf("expected first result 'SearchM newer quote' (most recent), got %q", quotes[0].Quote) + } + if quotes[1].Quote != "SearchM older quote" { + t.Errorf("expected second result 'SearchM older quote', got %q", quotes[1].Quote) + } +} diff --git a/internal/data/search_test.go b/internal/data/search_test.go new file mode 100644 index 0000000..6cf63d8 --- /dev/null +++ b/internal/data/search_test.go @@ -0,0 +1,332 @@ +package data + +import ( + "context" + "testing" + "time" +) + +// --------------- SearchIRCLinks tests --------------- + +func TestSearchIRCLinks_ByTitle(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + _, err := store.InsertIRCLink(ctx, "alice", "Golang Tutorial", "http://example.com/go", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + _, err = store.InsertIRCLink(ctx, "bob", "Rust Guide", "http://example.com/rust", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "Golang") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result, got %d", len(links)) + } + if links[0].Title != "Golang Tutorial" { + t.Errorf("expected title 'Golang Tutorial', got %q", links[0].Title) + } +} + +func TestSearchIRCLinks_ByURL(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + _, err := store.InsertIRCLink(ctx, "alice", "Some Page", "http://example.com/unique-path", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "unique-path") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result, got %d", len(links)) + } + if links[0].URL != "http://example.com/unique-path" { + t.Errorf("expected URL with 'unique-path', got %q", links[0].URL) + } +} + +func TestSearchIRCLinks_ByTag(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + id, err := store.InsertIRCLink(ctx, "alice", "Tagged Link", "http://example.com/tagged", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + _, err = store.CreateTag(ctx, Tag{ + Tag: "special-topic", + ResourceType: "link", + ResourceID: id, + CreatedBy: "alice", + }) + if err != nil { + t.Fatalf("CreateTag failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "special-topic") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result, got %d", len(links)) + } + if links[0].Title != "Tagged Link" { + t.Errorf("expected 'Tagged Link', got %q", links[0].Title) + } +} + +func TestSearchIRCLinks_NoMatch(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + _, err := store.InsertIRCLink(ctx, "alice", "Something", "http://example.com/a", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 0 { + t.Errorf("expected 0 results, got %d", len(links)) + } +} + +func TestSearchIRCLinks_OrderedByClicks(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + db := store.db + + _, err := store.InsertIRCLink(ctx, "alice", "Search Low", "http://example.com/search-low", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + _, err = store.InsertIRCLink(ctx, "bob", "Search High", "http://example.com/search-high", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + // Set different click counts + db.Model(&IRCLink{}).Where("title = ?", "Search Low").Update("clicks", 5) + db.Model(&IRCLink{}).Where("title = ?", "Search High").Update("clicks", 50) + + links, err := store.SearchIRCLinks(ctx, "Search") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 2 { + t.Fatalf("expected 2 results, got %d", len(links)) + } + if links[0].Title != "Search High" { + t.Errorf("expected first result 'Search High' (most clicks), got %q", links[0].Title) + } + if links[1].Title != "Search Low" { + t.Errorf("expected second result 'Search Low', got %q", links[1].Title) + } +} + +func TestSearchIRCLinks_ExcludesErrorPreviews(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // Insert two links — one will have an error preview, one won't + _, err := store.InsertIRCLink(ctx, "alice", "Good Link", "http://example.com/good", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + _, err = store.InsertIRCLink(ctx, "bob", "Bad Link", "http://example.com/bad", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + // Insert an error preview for the bad link (recent, so it's within the cache TTL) + errorData := []byte(`{"error":"status 404"}`) + if err := store.InsertLinkPreview(ctx, "http://example.com/bad", errorData); err != nil { + t.Fatalf("InsertLinkPreview failed: %v", err) + } + + links, err := store.SearchIRCLinks(ctx, "Link") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result (error link excluded), got %d", len(links)) + } + if links[0].Title != "Good Link" { + t.Errorf("expected 'Good Link', got %q", links[0].Title) + } +} + +func TestSearchIRCLinks_ExpiredErrorCacheIncluded(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + db := store.db + + // Insert link that is "recent" (< 10 days old) — its error cache TTL is 24h + _, err := store.InsertIRCLink(ctx, "alice", "Recoverable Link", "http://example.com/recover", "text/html") + if err != nil { + t.Fatalf("InsertIRCLink failed: %v", err) + } + + // Insert an error preview and backdate its updated_at to >24h ago + errorData := []byte(`{"error":"status 503"}`) + if err := store.InsertLinkPreview(ctx, "http://example.com/recover", errorData); err != nil { + t.Fatalf("InsertLinkPreview failed: %v", err) + } + // Backdate the preview's updated_at to 2 days ago (beyond the 24h TTL for recent links) + twoDaysAgo := time.Now().Add(-48 * time.Hour) + db.Model(&LinkPreview{}).Where("url = ?", "http://example.com/recover").Update("updated_at", twoDaysAgo) + + links, err := store.SearchIRCLinks(ctx, "Recoverable") + if err != nil { + t.Fatalf("SearchIRCLinks failed: %v", err) + } + if len(links) != 1 { + t.Fatalf("expected 1 result (expired error cache should be included), got %d", len(links)) + } + if links[0].Title != "Recoverable Link" { + t.Errorf("expected 'Recoverable Link', got %q", links[0].Title) + } +} + +// --------------- SearchQuotes tests --------------- + +func TestSearchQuotes_ByQuoteText(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + _, err := store.InsertQuote(ctx, "To be or not to be", "Shakespeare", "alice") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + _, err = store.InsertQuote(ctx, "I think therefore I am", "Descartes", "bob") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + quotes, err := store.SearchQuotes(ctx, "not to be") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 1 { + t.Fatalf("expected 1 result, got %d", len(quotes)) + } + if quotes[0].Author != "Shakespeare" { + t.Errorf("expected author 'Shakespeare', got %q", quotes[0].Author) + } +} + +func TestSearchQuotes_ByAuthor(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + _, err := store.InsertQuote(ctx, "Some quote", "UniqueAuthor42", "alice") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 1 { + t.Fatalf("expected 1 result, got %d", len(quotes)) + } + if quotes[0].Quote != "Some quote" { + t.Errorf("expected quote 'Some quote', got %q", quotes[0].Quote) + } +} + +func TestSearchQuotes_ByTag(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + id, err := store.InsertQuote(ctx, "A tagged quote", "someone", "alice") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + _, err = store.CreateTag(ctx, Tag{ + Tag: "philosophy", + ResourceType: "quote", + ResourceID: id, + CreatedBy: "alice", + }) + if err != nil { + t.Fatalf("CreateTag failed: %v", err) + } + + quotes, err := store.SearchQuotes(ctx, "philosophy") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 1 { + t.Fatalf("expected 1 result, got %d", len(quotes)) + } + if quotes[0].Quote != "A tagged quote" { + t.Errorf("expected 'A tagged quote', got %q", quotes[0].Quote) + } +} + +func TestSearchQuotes_NoMatch(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + _, err := store.InsertQuote(ctx, "Hello world", "author1", "poster1") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 0 { + t.Errorf("expected 0 results, got %d", len(quotes)) + } +} + +func TestSearchQuotes_OrderedByTimestamp(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + db := store.db + + _, err := store.InsertQuote(ctx, "Search older quote", "auth1", "poster") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + _, err = store.InsertQuote(ctx, "Search newer quote", "auth2", "poster") + if err != nil { + t.Fatalf("InsertQuote failed: %v", err) + } + + // Set timestamps: newer quote gets a more recent time + now := time.Now() + db.Model(&Quote{}).Where("quote = ?", "Search older quote").Update("timestamp", now.Add(-48*time.Hour)) + db.Model(&Quote{}).Where("quote = ?", "Search newer quote").Update("timestamp", now.Add(-1*time.Hour)) + + quotes, err := store.SearchQuotes(ctx, "Search") + if err != nil { + t.Fatalf("SearchQuotes failed: %v", err) + } + if len(quotes) != 2 { + t.Fatalf("expected 2 results, got %d", len(quotes)) + } + if quotes[0].Quote != "Search newer quote" { + t.Errorf("expected first result 'Search newer quote' (most recent), got %q", quotes[0].Quote) + } + if quotes[1].Quote != "Search older quote" { + t.Errorf("expected second result 'Search older quote', got %q", quotes[1].Quote) + } +}