readysite / website / internal / search / search_test.go
7.5 KB
search_test.go
package search

import (
	"testing"

	"github.com/readysite/readysite/pkg/database"
	"github.com/readysite/readysite/pkg/database/engines"
	"github.com/readysite/readysite/website/models"
)

func init() {
	models.DB = engines.NewMemory()
	models.Pages = database.Manage(models.DB, new(models.Page),
		database.WithIndex[models.Page]("ParentID"),
	)
	models.PageContents = database.Manage(models.DB, new(models.PageContent),
		database.WithIndex[models.PageContent]("PageID"),
		database.WithIndex[models.PageContent]("CreatedBy"),
	)
	models.Collections = database.Manage(models.DB, new(models.Collection))
	models.Documents = database.Manage(models.DB, new(models.Document),
		database.WithIndex[models.Document]("CollectionID"),
	)
	models.Files = database.Manage(models.DB, new(models.File),
		database.WithIndex[models.File]("UserID"),
	)
	models.FileContentCaches = database.Manage(models.DB, new(models.FileContentCache),
		database.WithIndex[models.FileContentCache]("FileID"),
	)
	models.Partials = database.Manage(models.DB, new(models.Partial),
		database.WithIndex[models.Partial]("Published"),
	)
	models.Notes = database.Manage(models.DB, new(models.Note),
		database.WithIndex[models.Note]("Type"),
	)
	models.Users = database.Manage(models.DB, new(models.User),
		database.WithUniqueIndex[models.User]("Email"),
	)
	models.SettingsStore = database.Manage(models.DB, new(models.Settings),
		database.WithUniqueIndex[models.Settings]("Key"),
	)
}

func TestStripHTML(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{"empty", "", ""},
		{"plain text", "hello world", "hello world"},
		{"simple tags", "<p>hello</p>", "hello"},
		{"nested tags", "<div><p>hello</p><p>world</p></div>", "hello world"},
		{"with attributes", `<a href="test">link text</a>`, "link text"},
		{"script tags", "<script>var x = 1;</script>content", "var x = 1; content"},
		{"whitespace", "<p>  hello  </p>  <p>  world  </p>", "hello world"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := StripHTML(tt.input)
			if got != tt.expected {
				t.Errorf("StripHTML(%q) = %q, want %q", tt.input, got, tt.expected)
			}
		})
	}
}

func TestFlattenJSON(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  string
	}{
		{"empty object", "{}", ""},
		{"simple string", `{"name":"Alice"}`, "Alice"},
		{"nested", `{"user":{"name":"Bob","email":"bob@test.com"}}`, "Bob bob@test.com"},
		{"array", `{"tags":["go","web"]}`, "go web"},
		{"mixed types", `{"name":"test","count":42,"active":true}`, "test"},
		{"invalid json", "not json", ""},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := FlattenJSON(tt.input)
			if got != tt.want {
				t.Errorf("FlattenJSON(%q) = %q, want %q", tt.input, got, tt.want)
			}
		})
	}
}

func TestSearchAndIndex(t *testing.T) {
	// Create FTS5 table
	if _, err := models.DB.Exec(createTableSQL); err != nil {
		t.Fatalf("Failed to create FTS5 table: %v", err)
	}

	// Clean slate
	models.DB.Exec("DELETE FROM SearchIndex")

	// Create test pages
	page := &models.Page{}
	page.ID = "about"
	models.Pages.Insert(page)

	content := &models.PageContent{
		PageID: "about",
		Title:  "About Us",
		HTML:   "<h1>About Our Company</h1><p>We build great software.</p>",
		Status: "published",
	}
	models.PageContents.Insert(content)

	// Create test collection
	col := &models.Collection{
		Name:        "Blog Posts",
		Description: "A collection of blog articles about technology",
	}
	col.ID = "blog-posts"
	models.Collections.Insert(col)

	// Create test document
	doc := &models.Document{
		CollectionID: "blog-posts",
		Data:         `{"title":"Getting Started with Go","body":"Go is a great language for web development."}`,
	}
	docID, _ := models.Documents.Insert(doc)

	// Build the index
	IndexAll()

	// Test search for page
	results, total, err := Search(SearchOptions{Query: "About"})
	if err != nil {
		t.Fatalf("Search failed: %v", err)
	}
	if total == 0 {
		t.Fatal("Expected results for 'About', got none")
	}

	found := false
	for _, r := range results {
		if r.EntityID == "about" && r.EntityType == "page" {
			found = true
			break
		}
	}
	if !found {
		t.Error("Expected to find page 'about' in results")
	}

	// Test search for collection
	results, total, err = Search(SearchOptions{Query: "blog"})
	if err != nil {
		t.Fatalf("Search failed: %v", err)
	}
	if total == 0 {
		t.Fatal("Expected results for 'blog', got none")
	}

	// Test search for document
	results, total, err = Search(SearchOptions{Query: "Go language"})
	if err != nil {
		t.Fatalf("Search failed: %v", err)
	}
	if total == 0 {
		t.Fatal("Expected results for 'Go language', got none")
	}

	found = false
	for _, r := range results {
		if r.EntityID == docID && r.EntityType == "document" {
			found = true
			if r.CollectionID != "blog-posts" {
				t.Errorf("Expected collection_id 'blog-posts', got %q", r.CollectionID)
			}
			break
		}
	}
	if !found {
		t.Error("Expected to find document in results")
	}

	// Test empty query
	results, total, err = Search(SearchOptions{Query: ""})
	if err != nil {
		t.Fatalf("Search with empty query failed: %v", err)
	}
	if total != 0 || len(results) != 0 {
		t.Error("Expected no results for empty query")
	}
}

func TestSearchFiltering(t *testing.T) {
	// Create FTS5 table (idempotent)
	models.DB.Exec(createTableSQL)
	models.DB.Exec("DELETE FROM SearchIndex")

	// Index various entity types
	insertEntry("Test Page", "some page content", "page", "p1", "page", "")
	insertEntry("Test Collection", "some collection content", "collection", "c1", "collection", "")
	insertEntry("Test Doc A", "document in blog", "document blog", "d1", "document", "blog")
	insertEntry("Test Doc B", "document in products", "document products", "d2", "document", "products")

	// Filter by entity type
	results, total, err := Search(SearchOptions{Query: "test", EntityType: "page"})
	if err != nil {
		t.Fatalf("Search with type filter failed: %v", err)
	}
	if total != 1 {
		t.Errorf("Expected 1 page result, got %d", total)
	}
	if len(results) > 0 && results[0].EntityType != "page" {
		t.Errorf("Expected entity_type 'page', got %q", results[0].EntityType)
	}

	// Filter by collection
	results, total, err = Search(SearchOptions{Query: "test", EntityType: "document", CollectionID: "blog"})
	if err != nil {
		t.Fatalf("Search with collection filter failed: %v", err)
	}
	if total != 1 {
		t.Errorf("Expected 1 document result for blog collection, got %d", total)
	}
	if len(results) > 0 && results[0].EntityID != "d1" {
		t.Errorf("Expected entity_id 'd1', got %q", results[0].EntityID)
	}

	// Pagination
	results, total, err = Search(SearchOptions{Query: "test", Page: 1, PerPage: 2})
	if err != nil {
		t.Fatalf("Paginated search failed: %v", err)
	}
	if total != 4 {
		t.Errorf("Expected total 4, got %d", total)
	}
	if len(results) != 2 {
		t.Errorf("Expected 2 results on page 1, got %d", len(results))
	}

	results, _, err = Search(SearchOptions{Query: "test", Page: 2, PerPage: 2})
	if err != nil {
		t.Fatalf("Paginated search page 2 failed: %v", err)
	}
	if len(results) != 2 {
		t.Errorf("Expected 2 results on page 2, got %d", len(results))
	}
}

func TestSanitizeFTS(t *testing.T) {
	tests := []struct {
		input string
		want  string
	}{
		{"hello world", `"hello" "world"`},
		{"", ""},
		{`test "injection"`, `"test" "injection"`},
		{"AND OR NOT", ""},
		{"hello AND world", `"hello" "world"`},
	}

	for _, tt := range tests {
		t.Run(tt.input, func(t *testing.T) {
			got := sanitizeFTS(tt.input)
			if got != tt.want {
				t.Errorf("sanitizeFTS(%q) = %q, want %q", tt.input, got, tt.want)
			}
		})
	}
}
← Back