ultisuite-backend/internal/search/service_test.go
R3D347HR4Y 0435e27ce6 Enhance search functionality with multi-engine support and configuration updates
- Added support for Typesense as a search engine alongside Meilisearch and PostgreSQL.
- Updated configuration structure to include Typesense parameters in `Config` and `.env.example`.
- Enhanced search handler and service to accommodate external search clients and filters.
- Implemented new tests for external search clients and search service functionalities.
- Updated project checklist to reflect completion of multi-index search features and contextual snippets.
2026-05-22 19:14:27 +02:00

185 lines
5.4 KiB
Go

package search
import (
"strings"
"testing"
)
func TestNormalizeEngine(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "postgres explicit", raw: "postgres", want: "postgres"},
{name: "meilisearch", raw: "meilisearch", want: "meilisearch"},
{name: "meilisearch mixed case", raw: "MeiliSearch", want: "meilisearch"},
{name: "meilisearch trimmed", raw: " meilisearch ", want: "meilisearch"},
{name: "typesense", raw: "typesense", want: "typesense"},
{name: "typesense mixed case", raw: "TypeSense", want: "typesense"},
{name: "empty defaults postgres", raw: "", want: "postgres"},
{name: "unknown defaults postgres", raw: "elasticsearch", want: "postgres"},
{name: "whitespace defaults postgres", raw: " ", want: "postgres"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeEngine(tt.raw); got != tt.want {
t.Fatalf("normalizeEngine(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
func TestParseTypes(t *testing.T) {
tests := []struct {
name string
raw string
want []string
}{
{
name: "empty defaults all",
raw: "",
want: []string{"mail", "contacts", "files", "events"},
},
{
name: "whitespace defaults all",
raw: " ",
want: []string{"mail", "contacts", "files", "events"},
},
{
name: "explicit subset",
raw: "mail,contacts",
want: []string{"mail", "contacts"},
},
{
name: "deduplicates",
raw: "mail,mail,contacts,contacts",
want: []string{"mail", "contacts"},
},
{
name: "trims and skips empty parts",
raw: " mail , , contacts ",
want: []string{"mail", "contacts"},
},
{
name: "preserves order",
raw: "events,mail,contacts",
want: []string{"events", "mail", "contacts"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseTypes(tt.raw)
if len(got) != len(tt.want) {
t.Fatalf("parseTypes(%q) = %v, want %v", tt.raw, got, tt.want)
}
for i := range tt.want {
if got[i] != tt.want[i] {
t.Fatalf("parseTypes(%q)[%d] = %q, want %q (full: %v)", tt.raw, i, got[i], tt.want[i], got)
}
}
})
}
}
func TestSplitSearchTerms(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{name: "empty", input: "", want: nil},
{name: "whitespace", input: " ", want: nil},
{name: "simple words", input: "hello world", want: []string{"hello", "world"}},
{name: "strips punctuation", input: "hello, world!", want: []string{"hello", "world"}},
{name: "deduplicates", input: "foo foo bar", want: []string{"foo", "bar"}},
{name: "mixed punctuation", input: "can't re-read", want: []string{"can", "t", "re", "read"}},
{name: "case normalized", input: "Hello HELLO", want: []string{"hello"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitSearchTerms(tt.input)
if len(got) == 0 && len(tt.want) == 0 {
return
}
if len(got) != len(tt.want) {
t.Fatalf("splitSearchTerms(%q) = %v, want %v", tt.input, got, tt.want)
}
for i := range tt.want {
if got[i] != tt.want[i] {
t.Fatalf("splitSearchTerms(%q)[%d] = %q, want %q (full: %v)", tt.input, i, got[i], tt.want[i], got)
}
}
})
}
}
func TestHighlightTerms(t *testing.T) {
tests := []struct {
name string
text string
q string
want string
}{
{name: "empty text", text: "", q: "hello", want: ""},
{name: "empty query unchanged", text: "Hello World", q: "", want: "Hello World"},
{name: "case insensitive preserves match casing", text: "Hello World", q: "hello", want: "<mark>Hello</mark> World"},
{name: "multiple terms", text: "Meet Alice and Bob", q: "alice bob", want: "Meet <mark>Alice</mark> and <mark>Bob</mark>"},
{name: "word boundary only", text: "hellish hello", q: "hello", want: "hellish <mark>hello</mark>"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := highlightTerms(tt.text, tt.q); got != tt.want {
t.Fatalf("highlightTerms(%q, %q) = %q, want %q", tt.text, tt.q, got, tt.want)
}
})
}
}
func TestContextualSnippet(t *testing.T) {
t.Run("highlights match in short text", func(t *testing.T) {
got := contextualSnippet("Invoice from Acme Corp", "acme", 180)
want := "Invoice from <mark>Acme</mark> Corp"
if got != want {
t.Fatalf("contextualSnippet short = %q, want %q", got, want)
}
})
t.Run("trims long content around match", func(t *testing.T) {
prefix := strings.Repeat("word ", 30)
text := prefix + "needle here " + strings.Repeat("tail ", 30)
got := contextualSnippet(text, "needle", 40)
if !strings.Contains(got, "<mark>needle</mark>") {
t.Fatalf("expected highlighted needle, got %q", got)
}
if !strings.HasPrefix(got, "...") {
t.Fatalf("expected leading ellipsis for trimmed snippet, got %q", got)
}
if !strings.HasSuffix(got, "...") {
t.Fatalf("expected trailing ellipsis for trimmed snippet, got %q", got)
}
if len(got) > 80 {
t.Fatalf("snippet too long (%d chars): %q", len(got), got)
}
})
t.Run("empty text", func(t *testing.T) {
if got := contextualSnippet(" ", "foo", 100); got != "" {
t.Fatalf("contextualSnippet empty = %q, want empty", got)
}
})
t.Run("default maxLen when zero", func(t *testing.T) {
long := strings.Repeat("x", 300) + " findme"
got := contextualSnippet(long, "findme", 0)
if !strings.Contains(got, "<mark>findme</mark>") {
t.Fatalf("expected highlighted findme with default maxLen, got %q", got)
}
})
}