diff --git a/internal/auth/password/password_test.go b/internal/auth/password/password_test.go index 1fcb17a..686c979 100644 --- a/internal/auth/password/password_test.go +++ b/internal/auth/password/password_test.go @@ -69,53 +69,53 @@ func TestVerifyPassword(t *testing.T) { } tests := []struct { - name string - password string - hash string - wantMatch bool - wantErr bool + name string + password string + hash string + wantMatch bool + wantErr bool }{ { - name: "correct password", - password: testPassword, - hash: testHash, - wantMatch: true, - wantErr: false, + name: "correct password", + password: testPassword, + hash: testHash, + wantMatch: true, + wantErr: false, }, { - name: "incorrect password", - password: "wrongpassword", - hash: testHash, - wantMatch: false, - wantErr: false, + name: "incorrect password", + password: "wrongpassword", + hash: testHash, + wantMatch: false, + wantErr: false, }, { - name: "empty password against valid hash", - password: "", - hash: testHash, - wantMatch: false, - wantErr: false, + name: "empty password against valid hash", + password: "", + hash: testHash, + wantMatch: false, + wantErr: false, }, { - name: "empty hash", - password: testPassword, - hash: "", - wantMatch: false, - wantErr: false, + name: "empty hash", + password: testPassword, + hash: "", + wantMatch: false, + wantErr: false, }, { - name: "invalid hash format", - password: testPassword, - hash: "invalid", - wantMatch: false, - wantErr: true, + name: "invalid hash format", + password: testPassword, + hash: "invalid", + wantMatch: false, + wantErr: true, }, { - name: "malformed hash - wrong prefix", - password: testPassword, - hash: "$bcrypt$invalid$hash", - wantMatch: false, - wantErr: true, + name: "malformed hash - wrong prefix", + password: testPassword, + hash: "$bcrypt$invalid$hash", + wantMatch: false, + wantErr: true, }, } @@ -190,4 +190,4 @@ func TestPasswordRoundTrip(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/index/bleve.go b/internal/index/bleve.go index 3856c00..00369fe 100644 --- a/internal/index/bleve.go +++ b/internal/index/bleve.go @@ -2,6 +2,8 @@ package index import ( "errors" + "strconv" + "github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" "github.com/blevesearch/bleve/v2/analysis/token/camelcase" @@ -10,7 +12,6 @@ import ( "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" "github.com/blevesearch/bleve/v2/search/query" "github.com/rs/zerolog/log" - "strconv" ) type BleveIndexer struct { @@ -53,6 +54,8 @@ func (i *BleveIndexer) open() (bleve.Index, error) { docMapping := bleve.NewDocumentMapping() docMapping.AddFieldMappingsAt("GistID", bleve.NewNumericFieldMapping()) + docMapping.AddFieldMappingsAt("UserID", bleve.NewNumericFieldMapping()) + docMapping.AddFieldMappingsAt("Visibility", bleve.NewNumericFieldMapping()) docMapping.AddFieldMappingsAt("Content", bleve.NewTextFieldMapping()) mapping := bleve.NewIndexMapping() @@ -74,6 +77,7 @@ func (i *BleveIndexer) open() (bleve.Index, error) { } docMapping.DefaultAnalyzer = "gistAnalyser" + mapping.DefaultMapping = docMapping return bleve.New(i.path, mapping) } @@ -105,39 +109,72 @@ func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata, var err error var indexerQuery query.Query if queryStr != "" { - contentQuery := bleve.NewMatchPhraseQuery(queryStr) - contentQuery.FieldVal = "Content" + // Use match query with fuzzy matching for more flexible content search + contentQuery := bleve.NewMatchQuery(queryStr) + contentQuery.SetField("Content") + contentQuery.SetFuzziness(2) indexerQuery = contentQuery } else { contentQuery := bleve.NewMatchAllQuery() indexerQuery = contentQuery } - privateQuery := bleve.NewBoolFieldQuery(false) - privateQuery.SetField("Private") + // Visibility filtering: show public gists (Visibility=0) OR user's own gists + visibilityZero := float64(0) + truee := true + publicQuery := bleve.NewNumericRangeInclusiveQuery(&visibilityZero, &visibilityZero, &truee, &truee) + publicQuery.SetField("Visibility") userIdMatch := float64(userId) - truee := true userIdQuery := bleve.NewNumericRangeInclusiveQuery(&userIdMatch, &userIdMatch, &truee, &truee) userIdQuery.SetField("UserID") - accessQuery := bleve.NewDisjunctionQuery(privateQuery, userIdQuery) + accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery) indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery) - addQuery := func(field, value string) { - if value != "" && value != "." { - q := bleve.NewMatchPhraseQuery(value) - q.FieldVal = field - indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q) - } - } + // Handle "All" field - search across all metadata fields with OR logic + if queryMetadata.All != "" { + allQueries := make([]query.Query, 0) - addQuery("Username", queryMetadata.Username) - addQuery("Title", queryMetadata.Title) - addQuery("Extensions", "."+queryMetadata.Extension) - addQuery("Filenames", queryMetadata.Filename) - addQuery("Languages", queryMetadata.Language) - addQuery("Topics", queryMetadata.Topic) + // Create match phrase queries for each field + fields := []struct { + field string + value string + }{ + {"Username", queryMetadata.All}, + {"Title", queryMetadata.All}, + {"Extensions", "." + queryMetadata.All}, + {"Filenames", queryMetadata.All}, + {"Languages", queryMetadata.All}, + {"Topics", queryMetadata.All}, + } + + for _, f := range fields { + q := bleve.NewMatchPhraseQuery(f.value) + q.FieldVal = f.field + allQueries = append(allQueries, q) + } + + // Combine all field queries with OR (disjunction) + allDisjunction := bleve.NewDisjunctionQuery(allQueries...) + indexerQuery = bleve.NewConjunctionQuery(indexerQuery, allDisjunction) + } else { + // Original behavior: add each metadata field with AND logic + addQuery := func(field, value string) { + if value != "" && value != "." { + q := bleve.NewMatchPhraseQuery(value) + q.FieldVal = field + indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q) + } + } + + addQuery("Username", queryMetadata.Username) + addQuery("Title", queryMetadata.Title) + addQuery("Extensions", "."+queryMetadata.Extension) + addQuery("Filenames", queryMetadata.Filename) + addQuery("Languages", queryMetadata.Language) + addQuery("Topics", queryMetadata.Topic) + } languageFacet := bleve.NewFacetRequest("Languages", 10) diff --git a/internal/index/bleve_test.go b/internal/index/bleve_test.go new file mode 100644 index 0000000..d2336d8 --- /dev/null +++ b/internal/index/bleve_test.go @@ -0,0 +1,162 @@ +package index + +import ( + "os" + "path/filepath" + "testing" +) + +// setupBleveIndexer creates a new BleveIndexer for testing +func setupBleveIndexer(t *testing.T) (*BleveIndexer, func()) { + t.Helper() + + // Create a temporary directory for the test index + tmpDir, err := os.MkdirTemp("", "bleve-test-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + + indexPath := filepath.Join(tmpDir, "test.index") + indexer := NewBleveIndexer(indexPath) + + // Initialize the indexer + err = indexer.Init() + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("Failed to initialize BleveIndexer: %v", err) + } + + // Store in the global atomicIndexer since Add/Remove use it + var idx Indexer = indexer + atomicIndexer.Store(&idx) + + // Return cleanup function + cleanup := func() { + atomicIndexer.Store(nil) + indexer.Close() + os.RemoveAll(tmpDir) + } + + return indexer, cleanup +} + +func TestBleveIndexerAddGist(t *testing.T) { + indexer, cleanup := setupBleveIndexer(t) + defer cleanup() + + testIndexerAddGist(t, indexer) +} + +func TestBleveIndexerAllFieldSearch(t *testing.T) { + indexer, cleanup := setupBleveIndexer(t) + defer cleanup() + + testIndexerAllFieldSearch(t, indexer) +} + +func TestBleveIndexerFuzzySearch(t *testing.T) { + indexer, cleanup := setupBleveIndexer(t) + defer cleanup() + + testIndexerFuzzySearch(t, indexer) +} + +func TestBleveIndexerSearchBasic(t *testing.T) { + indexer, cleanup := setupBleveIndexer(t) + defer cleanup() + + testIndexerSearchBasic(t, indexer) +} + +func TestBleveIndexerPagination(t *testing.T) { + indexer, cleanup := setupBleveIndexer(t) + defer cleanup() + + testIndexerPagination(t, indexer) +} + +// TestBleveIndexerInitAndClose tests Bleve-specific initialization and closing +func TestBleveIndexerInitAndClose(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "bleve-init-test-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + indexPath := filepath.Join(tmpDir, "test.index") + indexer := NewBleveIndexer(indexPath) + + // Test initialization + err = indexer.Init() + if err != nil { + t.Fatalf("Failed to initialize BleveIndexer: %v", err) + } + + if indexer.index == nil { + t.Fatal("Expected index to be initialized, got nil") + } + + // Test closing + indexer.Close() + + // Test reopening the same index + indexer2 := NewBleveIndexer(indexPath) + err = indexer2.Init() + if err != nil { + t.Fatalf("Failed to reopen BleveIndexer: %v", err) + } + defer indexer2.Close() + + if indexer2.index == nil { + t.Fatal("Expected reopened index to be initialized, got nil") + } +} + +// TestBleveIndexerUnicodeSearch tests that Unicode content can be indexed and searched +func TestBleveIndexerUnicodeSearch(t *testing.T) { + indexer, cleanup := setupBleveIndexer(t) + defer cleanup() + + // Add a gist with Unicode content + gist := &Gist{ + GistID: 100, + UserID: 100, + Visibility: 0, + Username: "testuser", + Title: "Unicode Test", + Content: "Hello world with unicode characters: café résumé naïve", + Filenames: []string{"test.txt"}, + Extensions: []string{".txt"}, + Languages: []string{"Text"}, + Topics: []string{"unicode"}, + CreatedAt: 1234567890, + UpdatedAt: 1234567890, + } + + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add gist: %v", err) + } + + // Search for unicode content + gistIDs, total, _, err := indexer.Search("café", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + + if total == 0 { + t.Skip("Unicode search may require specific index configuration") + return + } + + found := false + for _, id := range gistIDs { + if id == 100 { + found = true + break + } + } + if !found { + t.Log("Unicode gist not found in search results, but other results were returned") + } +} diff --git a/internal/index/gist.go b/internal/index/gist.go index 4db052e..76943b8 100644 --- a/internal/index/gist.go +++ b/internal/index/gist.go @@ -22,4 +22,5 @@ type SearchGistMetadata struct { Extension string Language string Topic string + All string } diff --git a/internal/index/indexer_test.go b/internal/index/indexer_test.go new file mode 100644 index 0000000..a1da1ff --- /dev/null +++ b/internal/index/indexer_test.go @@ -0,0 +1,1619 @@ +package index + +import ( + "fmt" + "testing" +) + +// initTestGists initializes the indexer with 1000 test gists +func initTestGists(t *testing.T, indexer Indexer) { + t.Helper() + + languages := []string{"Go", "Python", "JavaScript"} + extensions := []string{"go", "py", "js"} + usernames := []string{"alice", "bob", "charlie"} + topics := []string{"algorithms", "web", "database"} + + for i := 0; i < 1000; i++ { + langIdx := i % len(languages) // cycles 0,1,2,0,1,2,... + userIdx := i % len(usernames) // cycles 0,1,2,0,1,2,... + topicIdx := i % len(topics) // cycles 0,1,2,0,1,2,... + gistID := uint(i + 1) // GistIDs start at 1 + visibility := uint(i % 3) // cycles 0,1,2,0,1,2,... + + gist := &Gist{ + GistID: gistID, + UserID: uint(userIdx + 1), // alice=1, bob=2, charlie=3 + Visibility: visibility, + Username: usernames[userIdx], + Title: fmt.Sprintf("Test Gist %d", gistID), + Content: fmt.Sprintf("This is test gist number %d with some searchable content", gistID), + Filenames: []string{fmt.Sprintf("file%d.%s", gistID, extensions[langIdx])}, + Extensions: []string{extensions[langIdx]}, + Languages: []string{languages[langIdx]}, + Topics: []string{topics[topicIdx]}, + CreatedAt: 1234567890 + int64(gistID), + UpdatedAt: 1234567890 + int64(gistID), + } + + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to initialize test gist %d: %v", gistID, err) + } + } +} + +// testIndexerAddGist tests adding a gist to the index with comprehensive edge cases +func testIndexerAddGist(t *testing.T, indexer Indexer) { + t.Helper() + initTestGists(t, indexer) + + // Test 1: Add basic gist with multiple files + t.Run("AddBasicGist", func(t *testing.T) { + gist := &Gist{ + GistID: 1001, + UserID: 11, + Visibility: 0, + Username: "testuser", + Title: "Test Gist", + Content: "This is a test gist with some content", + Filenames: []string{"test.go", "readme.md"}, + Extensions: []string{"go", "md"}, + Languages: []string{"Go", "Markdown"}, + Topics: []string{"testing"}, + CreatedAt: 1234567890, + UpdatedAt: 1234567890, + } + + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add gist to index: %v", err) + } + + // Verify gist is searchable + gistIDs, total, _, err := indexer.Search("test gist", SearchGistMetadata{}, 11, 1) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if total == 0 { + t.Error("Expected to find the added gist") + } + found := false + for _, id := range gistIDs { + if id == 1001 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 1001 in search results") + } + }) + + // Test 2: Add gist and search by language + t.Run("AddAndSearchByLanguage", func(t *testing.T) { + gist := &Gist{ + GistID: 1002, + UserID: 11, + Visibility: 0, + Username: "testuser", + Title: "Rust Example", + Content: "fn main() { println!(\"Hello\"); }", + Filenames: []string{"main.rs"}, + Extensions: []string{"rs"}, + Languages: []string{"Rust"}, + Topics: []string{"systems"}, + CreatedAt: 1234567891, + UpdatedAt: 1234567891, + } + + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add Rust gist: %v", err) + } + + // Search by Rust language + metadata := SearchGistMetadata{Language: "Rust"} + gistIDs, total, _, err := indexer.Search("", metadata, 11, 1) + if err != nil { + t.Fatalf("Search by Rust language failed: %v", err) + } + if total == 0 { + t.Error("Expected to find Rust gist") + } + found := false + for _, id := range gistIDs { + if id == 1002 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 1002 in Rust search results") + } + }) + + // Test 3: Add gist with special characters and unicode + t.Run("AddGistWithUnicode", func(t *testing.T) { + gist := &Gist{ + GistID: 1003, + UserID: 11, + Visibility: 0, + Username: "testuser", + Title: "Unicode Test: café résumé naïve", + Content: "Special chars: @#$%^&*() and unicode: 你好世界 مرحبا العالم", + Filenames: []string{"unicode.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"unicode", "i18n"}, + CreatedAt: 1234567892, + UpdatedAt: 1234567892, + } + + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add unicode gist: %v", err) + } + + // Search for unicode content + _, total, _, err := indexer.Search("café", SearchGistMetadata{}, 11, 1) + if err != nil { + t.Fatalf("Search for unicode failed: %v", err) + } + // Note: Unicode search support may vary by indexer + if total > 0 { + t.Logf("Unicode search returned %d results", total) + } + }) + + // Test 4: Add gist with different visibility levels + t.Run("AddGistPrivate", func(t *testing.T) { + privateGist := &Gist{ + GistID: 1004, + UserID: 11, + Visibility: 1, + Username: "testuser", + Title: "Private Gist", + Content: "This is a private gist", + Filenames: []string{"private.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"private"}, + CreatedAt: 1234567893, + UpdatedAt: 1234567893, + } + + err := indexer.Add(privateGist) + if err != nil { + t.Fatalf("Failed to add private gist: %v", err) + } + + // User 11 should see their own private gist + gistIDs, total, _, err := indexer.Search("private gist", SearchGistMetadata{}, 11, 1) + if err != nil { + t.Fatalf("Search for private gist as owner failed: %v", err) + } + found := false + for _, id := range gistIDs { + if id == 1004 { + found = true + break + } + } + if !found && total > 0 { + t.Error("Expected owner to find their private gist") + } + + // User 999 should NOT see user 11's private gist + gistIDs2, _, _, err := indexer.Search("private gist", SearchGistMetadata{}, 999, 1) + if err != nil { + t.Fatalf("Search for private gist as other user failed: %v", err) + } + for _, id := range gistIDs2 { + if id == 1004 { + t.Error("Other user should not see private gist") + } + } + }) + + // Test 5: Add gist with empty optional fields + t.Run("AddGistMinimalFields", func(t *testing.T) { + gist := &Gist{ + GistID: 1005, + UserID: 11, + Visibility: 0, + Username: "testuser", + Title: "", + Content: "Minimal content", + Filenames: []string{}, + Extensions: []string{}, + Languages: []string{}, + Topics: []string{}, + CreatedAt: 1234567894, + UpdatedAt: 1234567894, + } + + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add minimal gist: %v", err) + } + + // Should still be searchable by content + _, total, _, err := indexer.Search("Minimal", SearchGistMetadata{}, 11, 1) + if err != nil { + t.Fatalf("Search for minimal gist failed: %v", err) + } + if total == 0 { + t.Error("Expected to find minimal gist by content") + } + }) + + // Test 6: Update existing gist (same GistID) + t.Run("UpdateExistingGist", func(t *testing.T) { + originalGist := &Gist{ + GistID: 1006, + UserID: 11, + Visibility: 0, + Username: "testuser", + Title: "Original Title", + Content: "Original content", + Filenames: []string{"original.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"original"}, + CreatedAt: 1234567895, + UpdatedAt: 1234567895, + } + + err := indexer.Add(originalGist) + if err != nil { + t.Fatalf("Failed to add original gist: %v", err) + } + + // Update with same GistID + updatedGist := &Gist{ + GistID: 1006, + UserID: 11, + Visibility: 0, + Username: "testuser", + Title: "Updated Title", + Content: "Updated content with new information", + Filenames: []string{"updated.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"updated"}, + CreatedAt: 1234567895, + UpdatedAt: 1234567900, + } + + err = indexer.Add(updatedGist) + if err != nil { + t.Fatalf("Failed to update gist: %v", err) + } + + // Search should find updated content, not original + gistIDs, total, _, err := indexer.Search("new information", SearchGistMetadata{}, 11, 1) + if err != nil { + t.Fatalf("Search for updated content failed: %v", err) + } + found := false + for _, id := range gistIDs { + if id == 1006 { + found = true + break + } + } + if !found && total > 0 { + t.Error("Expected to find updated gist by new content") + } + + // Old content should not be found + gistIDsOld, _, _, _ := indexer.Search("Original", SearchGistMetadata{}, 11, 1) + for _, id := range gistIDsOld { + if id == 1006 { + t.Error("Should not find gist by old content after update") + } + } + }) + + // Test 7: Add gist and verify by username filter + t.Run("AddAndSearchByUsername", func(t *testing.T) { + gist := &Gist{ + GistID: 1007, + UserID: 12, + Visibility: 0, + Username: "newuser", + Title: "New User Gist", + Content: "Content from new user", + Filenames: []string{"newuser.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"new"}, + CreatedAt: 1234567896, + UpdatedAt: 1234567896, + } + + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add new user gist: %v", err) + } + + // Search by username + metadata := SearchGistMetadata{Username: "newuser"} + gistIDs, total, _, err := indexer.Search("", metadata, 12, 1) + if err != nil { + t.Fatalf("Search by username failed: %v", err) + } + if total == 0 { + t.Error("Expected to find gist by username filter") + } + found := false + for _, id := range gistIDs { + if id == 1007 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 1007 by username") + } + }) + + // Test 8: Add gist with multiple languages and topics + t.Run("AddGistMultipleTags", func(t *testing.T) { + gist := &Gist{ + GistID: 1008, + UserID: 11, + Visibility: 0, + Username: "testuser", + Title: "Multi-language Project", + Content: "Mixed language project with Go, Python, and JavaScript", + Filenames: []string{"main.go", "script.py", "app.js"}, + Extensions: []string{"go", "py", "js"}, + Languages: []string{"Go", "Python", "JavaScript"}, + Topics: []string{"fullstack", "microservices", "api"}, + CreatedAt: 1234567897, + UpdatedAt: 1234567897, + } + + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add multi-language gist: %v", err) + } + + // Search by one of the topics + metadata := SearchGistMetadata{Topic: "microservices"} + gistIDs, total, _, err := indexer.Search("", metadata, 11, 1) + if err != nil { + t.Fatalf("Search by topic failed: %v", err) + } + found := false + for _, id := range gistIDs { + if id == 1008 { + found = true + break + } + } + if !found && total > 0 { + t.Error("Expected to find multi-language gist by topic") + } + }) + + // Test 9: Add gist with long content + t.Run("AddGistLongContent", func(t *testing.T) { + longContent := "" + for i := 0; i < 1000; i++ { + longContent += fmt.Sprintf("Line %d: This is a long gist with lots of content. ", i) + } + + gist := &Gist{ + GistID: 1009, + UserID: 11, + Visibility: 0, + Username: "testuser", + Title: "Long Gist", + Content: longContent, + Filenames: []string{"long.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"large"}, + CreatedAt: 1234567898, + UpdatedAt: 1234567898, + } + + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add long gist: %v", err) + } + + // Search for content from the middle + gistIDs, total, _, err := indexer.Search("Line 500", SearchGistMetadata{}, 11, 1) + if err != nil { + t.Fatalf("Search in long content failed: %v", err) + } + found := false + for _, id := range gistIDs { + if id == 1009 { + found = true + break + } + } + if !found && total > 0 { + t.Error("Expected to find long gist by content in the middle") + } + }) +} + +// testIndexerSearchBasic tests basic search functionality with edge cases +func testIndexerSearchBasic(t *testing.T, indexer Indexer) { + t.Helper() + initTestGists(t, indexer) + + // Test 1: Search by content - all init gists have "searchable content" + t.Run("SearchByContent", func(t *testing.T) { + gistIDs, total, languageCounts, err := indexer.Search("searchable", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + // Distribution: alice=334 public Go/algorithms, bob=333 private Python/web, charlie=333 private JS/database + // As user 1 (alice), we only see alice's public gists: 334 + if total != 334 { + t.Errorf("Expected alice to see 334 gists, got %d", total) + } + if len(gistIDs) == 0 { + t.Error("Expected non-empty gist IDs") + } + // Only Go should appear in language facets for alice + if len(languageCounts) == 0 { + t.Error("Expected language facets to be populated") + } + if languageCounts["go"] != 334 { + t.Errorf("Expected 334 Go gists in facets, got %d", languageCounts["go"]) + } + }) + + // Test 2: Search by specific language - Go + t.Run("SearchByLanguage", func(t *testing.T) { + metadata := SearchGistMetadata{Language: "Go"} + gistIDs, total, _, err := indexer.Search("", metadata, 1, 1) + if err != nil { + t.Fatalf("Search by language failed: %v", err) + } + // All Go gists are alice's (i=0,3,6,...) = 334 gists + // All are public + if total != 334 { + t.Errorf("Expected 334 Go gists, got %d", total) + } + // Verify GistID 1 (i=0) is in results + foundGoGist := false + for _, id := range gistIDs { + if id == 1 { + foundGoGist = true + break + } + } + if !foundGoGist && len(gistIDs) > 0 { + t.Error("Expected to find GistID 1 (Go) in results") + } + }) + + // Test 3: Search by specific username - alice + t.Run("SearchByUsername", func(t *testing.T) { + metadata := SearchGistMetadata{Username: "alice"} + _, total, _, err := indexer.Search("", metadata, 1, 1) + if err != nil { + t.Fatalf("Search by username failed: %v", err) + } + // alice has 334 gists at i=0,3,6,... + // All are public + if total != 334 { + t.Errorf("Expected 334 alice gists, got %d", total) + } + }) + + // Test 4: Search by extension - Python (bob's private files) + t.Run("SearchByExtension", func(t *testing.T) { + metadata := SearchGistMetadata{Extension: "py"} + _, total, _, err := indexer.Search("", metadata, 1, 1) + if err != nil { + t.Fatalf("Search by extension failed: %v", err) + } + // All .py files are bob's (i=1,4,7,...) = 333 files + // All are private (visibility=1) + // As user 1 (alice), we see 0 .py files + if total != 0 { + t.Errorf("Expected alice to see 0 .py files (bob's private), got %d", total) + } + }) + + // Test 5: Search by topic - algorithms + t.Run("SearchByTopic", func(t *testing.T) { + metadata := SearchGistMetadata{Topic: "algorithms"} + _, total, _, err := indexer.Search("", metadata, 1, 1) + if err != nil { + t.Fatalf("Search by topic failed: %v", err) + } + // All algorithms gists are alice's (i=0,3,6,...) = 334 gists + // All are public + if total != 334 { + t.Errorf("Expected 334 algorithms gists, got %d", total) + } + }) + + // Test 6: Combined filters - Go language + alice + t.Run("SearchCombinedFilters", func(t *testing.T) { + metadata := SearchGistMetadata{ + Language: "Go", + Username: "alice", + } + _, total, _, err := indexer.Search("", metadata, 1, 1) + if err != nil { + t.Fatalf("Search with combined filters failed: %v", err) + } + // Go AND alice are the same set (i=0,3,6,...) = 334 gists + // All are public + if total != 334 { + t.Errorf("Expected 334 Go+alice gists, got %d", total) + } + }) + + // Test 7: Search with no results + t.Run("SearchNoResults", func(t *testing.T) { + gistIDs, total, _, err := indexer.Search("nonexistentquery", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Search with no results failed: %v", err) + } + if total != 0 { + t.Errorf("Expected 0 results for non-existent query, got %d", total) + } + if len(gistIDs) != 0 { + t.Error("Expected empty gist IDs for non-existent query") + } + }) + + // Test 8: Empty query returns all accessible gists + t.Run("SearchEmptyQuery", func(t *testing.T) { + gistIDs, total, languageCounts, err := indexer.Search("", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Empty search failed: %v", err) + } + // As user 1 (alice), only sees alice's 334 public gists + if total != 334 { + t.Errorf("Expected 334 gists with empty query, got %d", total) + } + if len(gistIDs) == 0 { + t.Error("Expected non-empty gist IDs with empty query") + } + // Should have only Go in facets (alice's language) + if len(languageCounts) == 0 { + t.Error("Expected language facets with empty query") + } + if languageCounts["go"] != 334 { + t.Errorf("Expected 334 Go in facets, got %d", languageCounts["go"]) + } + }) + + // Test 9: Pagination + t.Run("SearchPagination", func(t *testing.T) { + // As user 1, we have 334 gists total + // Page 1 + gistIDs1, total, _, err := indexer.Search("searchable", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Page 1 search failed: %v", err) + } + if total != 334 { + t.Errorf("Expected 334 total results, got %d", total) + } + if len(gistIDs1) == 0 { + t.Error("Expected results on page 1") + } + + // Page 2 + gistIDs2, _, _, err := indexer.Search("searchable", SearchGistMetadata{}, 1, 2) + if err != nil { + t.Fatalf("Page 2 search failed: %v", err) + } + + // With 334 results and typical page size of 10, we should have page 2 + if len(gistIDs2) == 0 { + t.Error("Expected results on page 2") + } + + // Ensure pages are different + if len(gistIDs1) > 0 && len(gistIDs2) > 0 && gistIDs1[0] == gistIDs2[0] { + t.Error("Page 1 and page 2 should have different first results") + } + }) + + // Test 10: Search as different user (visibility filtering) + t.Run("SearchVisibilityFiltering", func(t *testing.T) { + // Search as user 2 (bob) + // bob has 333 gists at i=1,4,7,... with visibility=1 (private) + // As user 2, bob sees: alice's 334 public gists + bob's own 333 gists = 667 total + _, total, _, err := indexer.Search("", SearchGistMetadata{}, 2, 1) + if err != nil { + t.Fatalf("Search as user 2 failed: %v", err) + } + if total != 667 { + t.Errorf("Expected bob to see 667 gists (334 public + 333 own), got %d", total) + } + + // Search as non-existent user (should only see public gists) + gistIDsPublic, totalPublic, _, err := indexer.Search("", SearchGistMetadata{}, 999, 1) + if err != nil { + t.Fatalf("Search as user 999 failed: %v", err) + } + // Non-existent user only sees alice's 334 public gists + if totalPublic != 334 { + t.Errorf("Expected non-existent user to see 334 public gists, got %d", totalPublic) + } + + // Public gists (334) should be less than what user 2 sees (667) + if totalPublic >= total { + t.Errorf("Non-existent user sees %d gists, should be less than user 2's %d", totalPublic, total) + } + + // Verify we can find GistID 1 (alice's public gist) + foundPublic := false + for _, id := range gistIDsPublic { + if id == 1 { + foundPublic = true + break + } + } + if !foundPublic { + t.Error("Expected to find GistID 1 (alice's public gist) in results") + } + }) + + // Test 11: Language facets validation + t.Run("LanguageFacets", func(t *testing.T) { + _, _, languageCounts, err := indexer.Search("", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Search for facets failed: %v", err) + } + if len(languageCounts) != 1 { + t.Errorf("Expected 1 language in facets (Go), got %d", len(languageCounts)) + } + // As user 1 (alice), should only see Go with count 334 + if languageCounts["go"] != 334 { + t.Errorf("Expected 334 Go in facets, got %d", languageCounts["go"]) + } + // Python and JavaScript should not appear (bob's and charlie's private gists) + if languageCounts["Python"] != 0 { + t.Errorf("Expected 0 Python in facets, got %d", languageCounts["Python"]) + } + if languageCounts["JavaScript"] != 0 { + t.Errorf("Expected 0 JavaScript in facets, got %d", languageCounts["JavaScript"]) + } + }) +} + +// testIndexerAllFieldSearch tests the "All" field OR search functionality +func testIndexerAllFieldSearch(t *testing.T, indexer Indexer) { + t.Helper() + initTestGists(t, indexer) + + // Add test gists with distinct values in different fields + testGists := []*Gist{ + { + GistID: 3001, + UserID: 100, + Visibility: 0, + Username: "testuser_unique", + Title: "Configuration Guide", + Content: "How to configure your application", + Filenames: []string{"config.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"configuration"}, + CreatedAt: 1234567890, + UpdatedAt: 1234567890, + }, + { + GistID: 3002, + UserID: 100, + Visibility: 0, + Username: "developer", + Title: "Testing unique features", + Content: "Testing best practices", + Filenames: []string{"test.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"testing"}, + CreatedAt: 1234567891, + UpdatedAt: 1234567891, + }, + { + GistID: 3003, + UserID: 100, + Visibility: 0, + Username: "coder", + Title: "API Documentation", + Content: "REST API documentation", + Filenames: []string{"api.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Markdown"}, + Topics: []string{"unique_topic"}, + CreatedAt: 1234567892, + UpdatedAt: 1234567892, + }, + { + GistID: 3004, + UserID: 100, + Visibility: 0, + Username: "programmer", + Title: "Code Examples", + Content: "Code examples for beginners", + Filenames: []string{"unique_file.rb"}, + Extensions: []string{"rb"}, + Languages: []string{"Ruby"}, + Topics: []string{"examples"}, + CreatedAt: 1234567893, + UpdatedAt: 1234567893, + }, + { + GistID: 3005, + UserID: 100, + Visibility: 0, + Username: "admin", + Title: "Setup Instructions", + Content: "How to setup the project", + Filenames: []string{"setup.sh"}, + Extensions: []string{"sh"}, + Languages: []string{"Shell"}, + Topics: []string{"setup"}, + CreatedAt: 1234567894, + UpdatedAt: 1234567894, + }, + } + + for _, gist := range testGists { + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add test gist %d: %v", gist.GistID, err) + } + } + + // Test 1: All field matches username + t.Run("AllFieldMatchesUsername", func(t *testing.T) { + metadata := SearchGistMetadata{All: "testuser_unique"} + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field search failed: %v", err) + } + if total == 0 { + t.Error("Expected to find gist by username via All field") + } + found := false + for _, id := range gistIDs { + if id == 3001 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 3001 by username via All field") + } + }) + + // Test 2: All field matches title + t.Run("AllFieldMatchesTitle", func(t *testing.T) { + metadata := SearchGistMetadata{All: "unique features"} + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field search failed: %v", err) + } + if total == 0 { + t.Error("Expected to find gist by title via All field") + } + found := false + for _, id := range gistIDs { + if id == 3002 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 3002 by title via All field") + } + }) + + // Test 3: All field matches language + t.Run("AllFieldMatchesLanguage", func(t *testing.T) { + metadata := SearchGistMetadata{All: "Ruby"} + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field search failed: %v", err) + } + if total == 0 { + t.Error("Expected to find gist by language via All field") + } + found := false + for _, id := range gistIDs { + if id == 3004 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 3004 by language via All field") + } + }) + + // Test 4: All field matches topic + t.Run("AllFieldMatchesTopic", func(t *testing.T) { + metadata := SearchGistMetadata{All: "unique_topic"} + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field search failed: %v", err) + } + if total == 0 { + t.Error("Expected to find gist by topic via All field") + } + found := false + for _, id := range gistIDs { + if id == 3003 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 3003 by topic via All field") + } + }) + + // Test 5: All field matches extension + t.Run("AllFieldMatchesExtension", func(t *testing.T) { + metadata := SearchGistMetadata{All: "sh"} + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field search failed: %v", err) + } + if total == 0 { + t.Error("Expected to find gist by extension via All field") + } + found := false + for _, id := range gistIDs { + if id == 3005 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 3005 by extension via All field") + } + }) + + // Test 6: All field matches filename + t.Run("AllFieldMatchesFilename", func(t *testing.T) { + metadata := SearchGistMetadata{All: "unique_file.rb"} + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field search failed: %v", err) + } + if total == 0 { + t.Error("Expected to find gist by filename via All field") + } + found := false + for _, id := range gistIDs { + if id == 3004 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 3004 by filename via All field") + } + }) + + // Test 7: All field OR behavior - matches across different fields + t.Run("AllFieldORBehavior", func(t *testing.T) { + // "unique" appears in: username (3001), title (3002), topic (3003), filename (3004) + metadata := SearchGistMetadata{All: "unique"} + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field OR search failed: %v", err) + } + if total < 4 { + t.Errorf("Expected at least 4 results from OR search, got %d", total) + } + + // Verify we found gists from different fields + foundIDs := make(map[uint]bool) + for _, id := range gistIDs { + if id >= 3001 && id <= 3004 { + foundIDs[id] = true + } + } + + expectedIDs := []uint{3001, 3002, 3003, 3004} + for _, expectedID := range expectedIDs { + if !foundIDs[expectedID] { + t.Errorf("Expected to find GistID %d in OR search results", expectedID) + } + } + }) + + // Test 8: All field returns more results than specific field (OR vs AND) + t.Run("AllFieldVsSpecificField", func(t *testing.T) { + // Search with All field + metadataAll := SearchGistMetadata{All: "unique"} + _, totalAll, _, err := indexer.Search("", metadataAll, 100, 1) + if err != nil { + t.Fatalf("All field search failed: %v", err) + } + + // Search with specific username field only + metadataSpecific := SearchGistMetadata{Username: "testuser_unique"} + _, totalSpecific, _, err := indexer.Search("", metadataSpecific, 100, 1) + if err != nil { + t.Fatalf("Specific field search failed: %v", err) + } + + // All field should return more results (OR) than specific field + if totalAll <= totalSpecific { + t.Errorf("All field (OR) should return more results (%d) than specific field (%d)", totalAll, totalSpecific) + } + }) + + // Test 9: All field with no matches + t.Run("AllFieldNoMatches", func(t *testing.T) { + metadata := SearchGistMetadata{All: "nonexistentvalue12345"} + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field no match search failed: %v", err) + } + if total != 0 { + t.Errorf("Expected 0 results for non-existent value, got %d", total) + } + if len(gistIDs) != 0 { + t.Error("Expected empty gist IDs for non-existent value") + } + }) + + // Test 10: All field is mutually exclusive with specific fields + t.Run("AllFieldIgnoresOtherFields", func(t *testing.T) { + // When All is specified, other specific fields should be ignored + metadata := SearchGistMetadata{ + All: "unique", + Username: "nonexistent", // This should be ignored + Language: "NonExistent", // This should be ignored + } + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field with other fields search failed: %v", err) + } + // Should still find results because All is used (and other fields are ignored) + if total < 4 { + t.Errorf("Expected All field to be used (ignoring other fields), got %d results", total) + } + // Verify we found gists matching "unique" + foundAny := false + for _, id := range gistIDs { + if id >= 3001 && id <= 3004 { + foundAny = true + break + } + } + if !foundAny { + t.Error("Expected All field to override specific fields and find results") + } + }) + + // Test 11: All field with content query + t.Run("AllFieldWithContentQuery", func(t *testing.T) { + // All field searches metadata, content query searches content + // Both should work together + metadata := SearchGistMetadata{All: "Ruby"} + gistIDs, total, _, err := indexer.Search("examples", metadata, 100, 1) + if err != nil { + t.Fatalf("All field with content query failed: %v", err) + } + // Should find gist 3004 which has Ruby language AND "examples" in content + if total == 0 { + t.Error("Expected to find gist matching both All field and content query") + } + found := false + for _, id := range gistIDs { + if id == 3004 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 3004 matching both conditions") + } + }) + + // Test 12: All field case insensitivity + t.Run("AllFieldCaseInsensitive", func(t *testing.T) { + // Search with different case + metadata := SearchGistMetadata{All: "RUBY"} + gistIDs, total, _, err := indexer.Search("", metadata, 100, 1) + if err != nil { + t.Fatalf("All field case insensitive search failed: %v", err) + } + if total == 0 { + t.Error("Expected case insensitive match for All field") + } + found := false + for _, id := range gistIDs { + if id == 3004 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Case insensitive All field search returned results but not exact match") + } + }) +} + +// testIndexerFuzzySearch tests fuzzy search functionality (typo tolerance) +func testIndexerFuzzySearch(t *testing.T, indexer Indexer) { + t.Helper() + initTestGists(t, indexer) + + // Add test gists with specific content for fuzzy search testing + testGists := []*Gist{ + { + GistID: 2001, + UserID: 100, + Visibility: 0, + Username: "fuzzytest", + Title: "Algorithm Test", + Content: "This is a test about algorithms and data structures", + Filenames: []string{"algorithm.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"algorithms"}, + CreatedAt: 1234567890, + UpdatedAt: 1234567890, + }, + { + GistID: 2002, + UserID: 100, + Visibility: 0, + Username: "fuzzytest", + Title: "Python Guide", + Content: "A comprehensive guide to python programming language", + Filenames: []string{"python.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"python"}, + CreatedAt: 1234567891, + UpdatedAt: 1234567891, + }, + { + GistID: 2003, + UserID: 100, + Visibility: 0, + Username: "fuzzytest", + Title: "Database Fundamentals", + Content: "Understanding relational databases and SQL queries", + Filenames: []string{"database.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"database"}, + CreatedAt: 1234567892, + UpdatedAt: 1234567892, + }, + { + GistID: 2004, + UserID: 100, + Visibility: 0, + Username: "fuzzytest", + Title: "JavaScript Essentials", + Content: "Essential javascript concepts for web development", + Filenames: []string{"javascript.txt"}, + Extensions: []string{"txt"}, + Languages: []string{"Text"}, + Topics: []string{"javascript"}, + CreatedAt: 1234567893, + UpdatedAt: 1234567893, + }, + } + + for _, gist := range testGists { + err := indexer.Add(gist) + if err != nil { + t.Fatalf("Failed to add fuzzy test gist %d: %v", gist.GistID, err) + } + } + + // Test 1: Exact match should work + t.Run("ExactMatch", func(t *testing.T) { + gistIDs, total, _, err := indexer.Search("algorithms", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("Exact match search failed: %v", err) + } + if total == 0 { + t.Error("Expected to find gist with exact match 'algorithms'") + } + found := false + for _, id := range gistIDs { + if id == 2001 { + found = true + break + } + } + if !found { + t.Error("Expected to find GistID 2001 with exact match") + } + }) + + // Test 2: 1 character typo - substitution + t.Run("OneCharSubstitution", func(t *testing.T) { + // "algoritm" instead of "algorithm" (missing 'h') + gistIDs, total, _, err := indexer.Search("algoritm", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("1-char typo search failed: %v", err) + } + if total == 0 { + t.Error("Expected fuzzy search to find 'algorithm' with typo 'algoritm'") + } + found := false + for _, id := range gistIDs { + if id == 2001 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Fuzzy search returned results but not the expected gist (may be acceptable)") + } + }) + + // Test 3: 1 character typo - deletion + t.Run("OneCharDeletion", func(t *testing.T) { + // "pythn" instead of "python" (missing 'o') + gistIDs, total, _, err := indexer.Search("pythn", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("1-char deletion search failed: %v", err) + } + if total == 0 { + t.Error("Expected fuzzy search to find 'python' with typo 'pythn'") + } + found := false + for _, id := range gistIDs { + if id == 2002 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Fuzzy search returned results but not the expected gist") + } + }) + + // Test 4: 1 character typo - insertion (extra character) + t.Run("OneCharInsertion", func(t *testing.T) { + // "pythonn" instead of "python" (extra 'n') + gistIDs, total, _, err := indexer.Search("pythonn", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("1-char insertion search failed: %v", err) + } + if total == 0 { + t.Error("Expected fuzzy search to find 'python' with typo 'pythonn'") + } + found := false + for _, id := range gistIDs { + if id == 2002 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Fuzzy search returned results but not the expected gist") + } + }) + + // Test 5: 2 character typos - should still match with fuzziness=2 + t.Run("TwoCharTypos", func(t *testing.T) { + // "databse" instead of "database" (missing 'a', transposed 's') + gistIDs, total, _, err := indexer.Search("databse", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("2-char typo search failed: %v", err) + } + if total == 0 { + t.Error("Expected fuzzy search to find 'database' with typo 'databse'") + } + found := false + for _, id := range gistIDs { + if id == 2003 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Fuzzy search returned results but not the expected gist with 2 typos") + } + }) + + // Test 6: 2 character typos - different word + t.Run("TwoCharTyposDifferentWord", func(t *testing.T) { + // "javasript" instead of "javascript" (missing 'c') + gistIDs, total, _, err := indexer.Search("javasript", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("2-char typo search failed: %v", err) + } + if total == 0 { + t.Error("Expected fuzzy search to find 'javascript' with typo 'javasript'") + } + found := false + for _, id := range gistIDs { + if id == 2004 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Fuzzy search returned results but not the expected gist") + } + }) + + // Test 7: 3 character typos - should NOT match (beyond fuzziness=2) + t.Run("ThreeCharTyposShouldNotMatch", func(t *testing.T) { + // "algorthm" instead of "algorithm" (missing 'i', 't', 'h') - too different + gistIDs, _, _, err := indexer.Search("algorthm", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("3-char typo search failed: %v", err) + } + // With fuzziness=2, this might or might not match depending on the algorithm + // We'll just log the result + found := false + for _, id := range gistIDs { + if id == 2001 { + found = true + break + } + } + if found { + t.Log("3-char typo matched (fuzzy search is very lenient)") + } else { + t.Log("3-char typo did not match as expected") + } + }) + + // Test 8: Transposition (swapped characters) + t.Run("CharacterTransposition", func(t *testing.T) { + // "pyhton" instead of "python" (swapped 'ht') + gistIDs, total, _, err := indexer.Search("pyhton", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("Transposition search failed: %v", err) + } + if total == 0 { + t.Error("Expected fuzzy search to find 'python' with transposition 'pyhton'") + } + found := false + for _, id := range gistIDs { + if id == 2002 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Fuzzy search returned results but not the expected gist with transposition") + } + }) + + // Test 9: Case insensitivity with fuzzy search + t.Run("CaseInsensitiveWithFuzzy", func(t *testing.T) { + // "PYTHN" (uppercase with typo) + gistIDs, total, _, err := indexer.Search("PYTHN", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("Case insensitive fuzzy search failed: %v", err) + } + if total == 0 { + t.Error("Expected fuzzy search to find 'python' with 'PYTHN'") + } + found := false + for _, id := range gistIDs { + if id == 2002 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Case insensitive fuzzy search returned results but not expected gist") + } + }) + + // Test 10: Multiple words with typos + t.Run("MultipleWordsWithTypos", func(t *testing.T) { + // "relatonal databse" instead of "relational database" + gistIDs, total, _, err := indexer.Search("relatonal databse", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("Multi-word fuzzy search failed: %v", err) + } + if total == 0 { + t.Error("Expected fuzzy search to find 'relational database' with typos") + } + found := false + for _, id := range gistIDs { + if id == 2003 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Multi-word fuzzy search returned results but not expected gist") + } + }) + + // Test 11: Short words with typos (edge case) + t.Run("ShortWordsWithTypos", func(t *testing.T) { + // "SLQ" instead of "SQL" (1 char typo on short word) + gistIDs, total, _, err := indexer.Search("SLQ", SearchGistMetadata{}, 100, 1) + if err != nil { + t.Fatalf("Short word fuzzy search failed: %v", err) + } + // Short words might be more sensitive to typos + found := false + for _, id := range gistIDs { + if id == 2003 { + found = true + break + } + } + if !found && total > 0 { + t.Log("Short word fuzzy search is challenging, returned other results") + } else if found { + t.Log("Short word fuzzy search successfully matched") + } + }) + + // Test 12: Fuzzy search combined with metadata filters + t.Run("FuzzySearchWithMetadataFilters", func(t *testing.T) { + // Search with typo AND username filter + metadata := SearchGistMetadata{Username: "fuzzytest"} + gistIDs, total, _, err := indexer.Search("algoritm", metadata, 100, 1) + if err != nil { + t.Fatalf("Fuzzy search with metadata failed: %v", err) + } + if total == 0 { + t.Error("Expected fuzzy search with filter to find results") + } + // All results should be from fuzzytest user + for _, id := range gistIDs { + if id >= 2001 && id <= 2004 { + // Expected + } else { + t.Errorf("Found unexpected GistID %d, should only match fuzzytest gists", id) + } + } + }) +} + +// testIndexerPagination tests pagination in search results +func testIndexerPagination(t *testing.T, indexer Indexer) { + t.Helper() + initTestGists(t, indexer) + + // Test 1: Basic pagination - pages should be different + t.Run("BasicPagination", func(t *testing.T) { + // Search as user 1 (alice) - should see 334 public gists + gistIDs1, total, _, err := indexer.Search("", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Page 1 search failed: %v", err) + } + if total != 334 { + t.Errorf("Expected 334 total results, got %d", total) + } + if len(gistIDs1) == 0 { + t.Fatal("Expected results on page 1") + } + + gistIDs2, _, _, err := indexer.Search("", SearchGistMetadata{}, 1, 2) + if err != nil { + t.Fatalf("Page 2 search failed: %v", err) + } + if len(gistIDs2) == 0 { + t.Error("Expected results on page 2") + } + + // Pages should have different first results + if gistIDs1[0] == gistIDs2[0] { + t.Error("Page 1 and page 2 returned the same first result") + } + }) + + // Test 2: Page size - verify results per page (page size = 10) + t.Run("PageSizeVerification", func(t *testing.T) { + gistIDs1, _, _, err := indexer.Search("", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Page 1 search failed: %v", err) + } + // With page size 10, first page should have 10 results (or up to 11 with +1 for hasMore check) + if len(gistIDs1) == 0 || len(gistIDs1) > 11 { + t.Errorf("Expected 1-11 results on page 1 (page size 10), got %d", len(gistIDs1)) + } + }) + + // Test 3: Total count consistency across pages + t.Run("TotalCountConsistency", func(t *testing.T) { + _, total1, _, err := indexer.Search("", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Page 1 search failed: %v", err) + } + _, total2, _, err := indexer.Search("", SearchGistMetadata{}, 1, 2) + if err != nil { + t.Fatalf("Page 2 search failed: %v", err) + } + if total1 != total2 { + t.Errorf("Total count inconsistent: page 1 reports %d, page 2 reports %d", total1, total2) + } + if total1 != 334 { + t.Errorf("Expected total count of 334, got %d", total1) + } + }) + + // Test 4: Out of bounds page + t.Run("OutOfBoundsPage", func(t *testing.T) { + // Page 100 is way beyond 334 results with page size 10 + gistIDs, _, _, err := indexer.Search("", SearchGistMetadata{}, 1, 100) + if err != nil { + t.Fatalf("Out of bounds page search failed: %v", err) + } + if len(gistIDs) != 0 { + t.Errorf("Expected 0 results for out of bounds page, got %d", len(gistIDs)) + } + }) + + // Test 5: Empty results pagination + t.Run("EmptyResultsPagination", func(t *testing.T) { + gistIDs, total, _, err := indexer.Search("nonexistentquery", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Empty search failed: %v", err) + } + if total != 0 { + t.Errorf("Expected 0 total for empty search, got %d", total) + } + if len(gistIDs) != 0 { + t.Errorf("Expected 0 results for empty search, got %d", len(gistIDs)) + } + }) + + // Test 6: No duplicate IDs across pages (accounting for +1 overlap for hasMore indicator) + t.Run("NoDuplicateIDs", func(t *testing.T) { + gistIDs1, _, _, err := indexer.Search("", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Page 1 search failed: %v", err) + } + gistIDs2, _, _, err := indexer.Search("", SearchGistMetadata{}, 1, 2) + if err != nil { + t.Fatalf("Page 2 search failed: %v", err) + } + + // The pagination returns 11 items but only displays 10 + // The 11th item is used as a "hasMore" indicator + // So we only check the first 10 items of each page for duplicates + page1Items := gistIDs1 + if len(gistIDs1) > 10 { + page1Items = gistIDs1[:10] + } + page2Items := gistIDs2 + if len(gistIDs2) > 10 { + page2Items = gistIDs2[:10] + } + + // Check for duplicates between displayed items only + for _, id1 := range page1Items { + for _, id2 := range page2Items { + if id1 == id2 { + t.Errorf("Found duplicate ID %d in displayed items of both pages", id1) + } + } + } + }) + + // Test 7: Pagination with metadata filters + t.Run("PaginationWithFilters", func(t *testing.T) { + // Filter by alice's username - should get 334 gists + metadata := SearchGistMetadata{Username: "alice"} + gistIDs1, total, _, err := indexer.Search("", metadata, 1, 1) + if err != nil { + t.Fatalf("Filtered page 1 search failed: %v", err) + } + if total != 334 { + t.Errorf("Expected 334 total results with filter, got %d", total) + } + if len(gistIDs1) == 0 { + t.Error("Expected results on filtered page 1") + } + + gistIDs2, _, _, err := indexer.Search("", metadata, 1, 2) + if err != nil { + t.Fatalf("Filtered page 2 search failed: %v", err) + } + if len(gistIDs2) == 0 { + t.Error("Expected results on filtered page 2") + } + + // Pages should be different + if gistIDs1[0] == gistIDs2[0] { + t.Error("Filtered pages should have different results") + } + }) + + // Test 8: Last page verification + t.Run("LastPageVerification", func(t *testing.T) { + // With 334 results and page size 10, page 34 should have 4 results + // Let's just verify the last page has some results + gistIDs34, total, _, err := indexer.Search("", SearchGistMetadata{}, 1, 34) + if err != nil { + t.Fatalf("Last page search failed: %v", err) + } + if total != 334 { + t.Errorf("Expected 334 total on last page, got %d", total) + } + if len(gistIDs34) == 0 { + t.Error("Expected results on last page (34)") + } + + // Page 35 should be empty + gistIDs35, _, _, err := indexer.Search("", SearchGistMetadata{}, 1, 35) + if err != nil { + t.Fatalf("Beyond last page search failed: %v", err) + } + if len(gistIDs35) != 0 { + t.Errorf("Expected 0 results on page 35 (beyond last page), got %d", len(gistIDs35)) + } + }) + + // Test 9: Multiple pages have different results + t.Run("MultiplePagesDifferent", func(t *testing.T) { + gistIDs1, _, _, err := indexer.Search("", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Page 1 search failed: %v", err) + } + gistIDs10, _, _, err := indexer.Search("", SearchGistMetadata{}, 1, 10) + if err != nil { + t.Fatalf("Page 10 search failed: %v", err) + } + gistIDs20, _, _, err := indexer.Search("", SearchGistMetadata{}, 1, 20) + if err != nil { + t.Fatalf("Page 20 search failed: %v", err) + } + + // All three pages should have results + if len(gistIDs1) == 0 || len(gistIDs10) == 0 || len(gistIDs20) == 0 { + t.Error("Expected results on pages 1, 10, and 20") + } + + // All should have different first results + if gistIDs1[0] == gistIDs10[0] || gistIDs1[0] == gistIDs20[0] || gistIDs10[0] == gistIDs20[0] { + t.Error("Pages 1, 10, and 20 should have different first results") + } + }) + + // Test 10: Pagination with different users (visibility filtering) + t.Run("PaginationWithVisibility", func(t *testing.T) { + // User 2 (bob) sees 667 gists (334 public alice + 333 own private) + gistIDs1Bob, totalBob, _, err := indexer.Search("", SearchGistMetadata{}, 2, 1) + if err != nil { + t.Fatalf("Bob page 1 search failed: %v", err) + } + if totalBob != 667 { + t.Errorf("Expected bob to see 667 gists, got %d", totalBob) + } + if len(gistIDs1Bob) == 0 { + t.Error("Expected results on page 1 for bob") + } + + // User 1 (alice) sees 334 gists + _, totalAlice, _, err := indexer.Search("", SearchGistMetadata{}, 1, 1) + if err != nil { + t.Fatalf("Alice page 1 search failed: %v", err) + } + if totalAlice != 334 { + t.Errorf("Expected alice to see 334 gists, got %d", totalAlice) + } + + // Bob sees more results than alice + if totalBob <= totalAlice { + t.Errorf("Bob should see more results (%d) than alice (%d)", totalBob, totalAlice) + } + }) +} diff --git a/internal/web/handlers/gist/all.go b/internal/web/handlers/gist/all.go index 550482e..6cc4d90 100644 --- a/internal/web/handlers/gist/all.go +++ b/internal/web/handlers/gist/all.go @@ -2,6 +2,9 @@ package gist import ( "errors" + "slices" + "strings" + "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/index" @@ -9,8 +12,6 @@ import ( "github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/handlers" "gorm.io/gorm" - "slices" - "strings" ) func AllGists(ctx *context.Context) error { @@ -188,6 +189,7 @@ func Search(ctx *context.Context) error { Extension: meta["extension"], Language: meta["language"], Topic: meta["topic"], + All: meta["all"], }, currentUserId, pageInt) if err != nil { return ctx.ErrorRes(500, "Error searching gists", err)