Files
gochat/internal/search/search_service_test.go
T

591 lines
20 KiB
Go

package search
import (
"context"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gochat/gochat/internal/model"
)
// mockSearchRepo implements SearchRepoInterface for testing.
type mockSearchRepo struct {
conversations []model.Conversation
convCount int64
convErr error
messages []model.Message
msgCount int64
msgErr error
contacts []model.Contact
contactCount int64
contactErr error
companies []model.Company
companyCount int64
companyErr error
articles []model.Article
articleCount int64
articleErr error
}
func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Conversation, int64, error) {
return m.conversations, m.convCount, m.convErr
}
func (m *mockSearchRepo) SearchMessages(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Message, int64, error) {
return m.messages, m.msgCount, m.msgErr
}
func (m *mockSearchRepo) SearchContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Contact, int64, error) {
return m.contacts, m.contactCount, m.contactErr
}
func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Company, int64, error) {
return m.companies, m.companyCount, m.companyErr
}
func (m *mockSearchRepo) SearchArticles(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Article, int64, error) {
return m.articles, m.articleCount, m.articleErr
}
func makeConversation(id uint, accountID uint, status string, labels string) model.Conversation {
return model.Conversation{
Base: model.Base{ID: id},
AccountID: accountID,
Status: status,
Labels: labels,
}
}
func makeMessage(id uint, accountID uint, content string, msgType string) model.Message {
return model.Message{
Base: model.Base{ID: id},
AccountID: accountID,
Content: content,
MessageType: msgType,
}
}
func makeContact(id uint, accountID uint, name string, email string, phone string) model.Contact {
return model.Contact{
Base: model.Base{ID: id},
AccountID: accountID,
Name: name,
Email: email,
PhoneNumber: phone,
}
}
func makeCompany(id uint, accountID uint, name string, domain string) model.Company {
return model.Company{
AccountID: accountID,
ID: id,
Name: name,
Domain: domain,
}
}
func makeArticle(id uint, accountID uint, title string, description string, content string, status string) model.Article {
return model.Article{
Base: model.Base{ID: id},
AccountID: accountID,
Title: title,
Description: description,
Content: content,
Status: status,
}
}
// --- SearchService constructor and GlobalSearch tests ---
func TestNewSearchService(t *testing.T) {
repo := &mockSearchRepo{}
svc := NewSearchService(repo)
assert.NotNil(t, svc)
}
func TestGlobalSearch_EmptyQueryNoFilters(t *testing.T) {
repo := &mockSearchRepo{}
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
resp, err := svc.GlobalSearch(context.Background(), 1, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(0), resp.TotalCount)
assert.Empty(t, resp.Results)
assert.Equal(t, "", resp.Query)
assert.Equal(t, 1, resp.Page)
assert.Equal(t, 25, resp.PerPage)
}
func TestGlobalSearch_ConversationsOnly(t *testing.T) {
repo := &mockSearchRepo{}
repo.conversations = []model.Conversation{
makeConversation(1, 1, "open", "support,billing"),
makeConversation(2, 1, "resolved", "feature"),
}
repo.convCount = 2
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25, Types: []SearchResultType{ResultTypeConversation}}
resp, err := svc.GlobalSearch(context.Background(), 1, "billing", filter)
require.NoError(t, err)
assert.Equal(t, int64(2), resp.TotalCount)
assert.Len(t, resp.Results, 2)
assert.Equal(t, int64(2), resp.ByType["conversation"])
}
func TestGlobalSearch_MessagesOnly(t *testing.T) {
repo := &mockSearchRepo{}
repo.messages = []model.Message{
makeMessage(10, 1, "Hello world test message", "incoming"),
}
repo.msgCount = 1
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25, Types: []SearchResultType{ResultTypeMessage}}
resp, err := svc.GlobalSearch(context.Background(), 1, "test", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), resp.TotalCount)
assert.Len(t, resp.Results, 1)
assert.Equal(t, ResultTypeMessage, resp.Results[0].Type)
}
func TestGlobalSearch_ArticlesOnly(t *testing.T) {
repo := &mockSearchRepo{}
repo.articles = []model.Article{
makeArticle(1, 1, "Getting Started", "How to use the platform", "Full content here", "published"),
}
repo.articleCount = 1
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25, Types: []SearchResultType{ResultTypeArticle}}
resp, err := svc.GlobalSearch(context.Background(), 1, "Getting", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), resp.TotalCount)
assert.Len(t, resp.Results, 1)
assert.Equal(t, ResultTypeArticle, resp.Results[0].Type)
}
func TestGlobalSearch_AllTypes(t *testing.T) {
repo := &mockSearchRepo{}
repo.conversations = []model.Conversation{makeConversation(1, 1, "open", "")}
repo.convCount = 1
repo.messages = []model.Message{makeMessage(10, 1, "test", "incoming")}
repo.msgCount = 1
repo.contacts = []model.Contact{makeContact(20, 1, "John Doe", "john@example.com", "")}
repo.contactCount = 1
repo.articles = []model.Article{makeArticle(30, 1, "Help", "desc", "content", "published")}
repo.articleCount = 1
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
resp, err := svc.GlobalSearch(context.Background(), 1, "test", filter)
require.NoError(t, err)
assert.Equal(t, int64(4), resp.TotalCount)
assert.Len(t, resp.Results, 4)
assert.Equal(t, int64(1), resp.ByType["conversation"])
assert.Equal(t, int64(1), resp.ByType["message"])
assert.Equal(t, int64(1), resp.ByType["contact"])
assert.Equal(t, int64(1), resp.ByType["article"])
}
func TestGlobalSearch_RepoErrorGraceful(t *testing.T) {
repo := &mockSearchRepo{}
repo.convErr = fmt.Errorf("db error")
repo.msgErr = fmt.Errorf("db error")
repo.contactErr = fmt.Errorf("db error")
repo.articleErr = fmt.Errorf("db error")
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
// GlobalSearch handles errors gracefully — logs warnings, continues
resp, err := svc.GlobalSearch(context.Background(), 1, "test", filter)
require.NoError(t, err)
assert.Equal(t, int64(0), resp.TotalCount)
assert.Empty(t, resp.Results)
}
func TestGlobalSearch_WithStatusFilter(t *testing.T) {
repo := &mockSearchRepo{}
repo.conversations = []model.Conversation{makeConversation(1, 1, "open", "")}
repo.convCount = 1
svc := NewSearchService(repo)
// Status filter triggers search even with empty query
filter := &SearchFilter{Page: 1, PerPage: 25, Status: []string{"open"}, Types: []SearchResultType{ResultTypeConversation}}
resp, err := svc.GlobalSearch(context.Background(), 1, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), resp.TotalCount)
assert.Len(t, resp.Results, 1)
}
// --- Convenience method tests ---
func TestSearchConversations_ConvenienceMethod(t *testing.T) {
repo := &mockSearchRepo{}
repo.conversations = []model.Conversation{makeConversation(1, 1, "open", "support")}
repo.convCount = 1
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
results, total, err := svc.SearchConversations(context.Background(), 1, "support", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, ResultTypeConversation, results[0].Type)
assert.Equal(t, uint(1), results[0].ID)
}
func TestSearchConversations_RepoError(t *testing.T) {
repo := &mockSearchRepo{}
repo.convErr = fmt.Errorf("db connection lost")
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
results, total, err := svc.SearchConversations(context.Background(), 1, "test", filter)
assert.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, int64(0), total)
}
func TestSearchMessages_ConvenienceMethod(t *testing.T) {
repo := &mockSearchRepo{}
repo.messages = []model.Message{makeMessage(10, 1, "Hello test message", "incoming")}
repo.msgCount = 1
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
results, total, err := svc.SearchMessages(context.Background(), 1, "test", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, ResultTypeMessage, results[0].Type)
}
func TestSearchContacts_ConvenienceMethod(t *testing.T) {
repo := &mockSearchRepo{}
repo.contacts = []model.Contact{makeContact(5, 1, "Alice", "alice@test.com", "")}
repo.contactCount = 1
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
results, total, err := svc.SearchContacts(context.Background(), 1, "alice", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, ResultTypeContact, results[0].Type)
}
func TestSearchCompanies_ConvenienceMethod(t *testing.T) {
repo := &mockSearchRepo{}
repo.companies = []model.Company{makeCompany(7, 1, "Acme", "acme.example")}
repo.companyCount = 1
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
results, total, err := svc.SearchCompanies(context.Background(), 1, "acme", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, ResultTypeCompany, results[0].Type)
}
func TestSearchArticles_ConvenienceMethod(t *testing.T) {
repo := &mockSearchRepo{}
repo.articles = []model.Article{makeArticle(1, 1, "FAQ", "Common questions", "Details here", "published")}
repo.articleCount = 1
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
results, total, err := svc.SearchArticles(context.Background(), 1, "FAQ", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, ResultTypeArticle, results[0].Type)
}
func TestSearchArticles_RepoError(t *testing.T) {
repo := &mockSearchRepo{}
repo.articleErr = fmt.Errorf("db error")
svc := NewSearchService(repo)
filter := &SearchFilter{Page: 1, PerPage: 25}
results, total, err := svc.SearchArticles(context.Background(), 1, "test", filter)
assert.Error(t, err)
assert.Nil(t, results)
assert.Equal(t, int64(0), total)
}
// --- Snippet and scoring helpers tests ---
func TestConversationSnippet(t *testing.T) {
conv := &model.Conversation{Base: model.Base{ID: 5}, Status: "open", Labels: "support,billing"}
t.Run("with empty query", func(t *testing.T) {
assert.Equal(t, "Conversation #5 — open", conversationSnippet(conv, ""))
})
t.Run("with matching label", func(t *testing.T) {
assert.Equal(t, "Labels containing: billing", conversationSnippet(conv, "billing"))
})
t.Run("with non-matching query", func(t *testing.T) {
assert.Equal(t, "Conversation #5 — open", conversationSnippet(conv, "random"))
})
t.Run("with empty labels", func(t *testing.T) {
convNoLabels := &model.Conversation{Base: model.Base{ID: 3}, Status: "resolved", Labels: ""}
assert.Equal(t, "Conversation #3 — resolved", conversationSnippet(convNoLabels, "test"))
})
}
func TestConversationScore(t *testing.T) {
t.Run("open status adds 2.0", func(t *testing.T) {
conv := &model.Conversation{Status: "open", Labels: ""}
assert.Equal(t, 3.0, conversationScore(conv, ""))
})
t.Run("non-open status gets base score", func(t *testing.T) {
conv := &model.Conversation{Status: "resolved", Labels: ""}
assert.Equal(t, 1.0, conversationScore(conv, ""))
})
t.Run("label match adds 2.0", func(t *testing.T) {
conv := &model.Conversation{Status: "open", Labels: "support,billing"}
assert.Equal(t, 5.0, conversationScore(conv, "billing"))
})
t.Run("no label match with query", func(t *testing.T) {
conv := &model.Conversation{Status: "resolved", Labels: "feature"}
assert.Equal(t, 1.0, conversationScore(conv, "billing"))
})
}
func TestMessageSnippet(t *testing.T) {
t.Run("short content unchanged", func(t *testing.T) {
msg := &model.Message{Content: "Hi there"}
assert.Equal(t, "Hi there", messageSnippet(msg, ""))
})
t.Run("long content truncated without query", func(t *testing.T) {
longContent := "This is a very long message content that exceeds the one hundred and twenty character limit for snippets in the search results display"
msg := &model.Message{Content: longContent}
snippet := messageSnippet(msg, "")
assert.True(t, len(snippet) <= 123) // 120 + "..."
assert.True(t, strings.HasSuffix(snippet, "..."))
})
t.Run("centered around query match", func(t *testing.T) {
msg := &model.Message{Content: "The quick brown fox jumps over the lazy dog while running through the forest and meadows in this very long message content that exceeds one hundred and twenty chars for testing"}
snippet := messageSnippet(msg, "fox")
assert.Contains(t, snippet, "fox")
assert.Contains(t, snippet, "...")
})
}
func TestMessageScore(t *testing.T) {
t.Run("base score without query", func(t *testing.T) {
msg := &model.Message{Content: "hello", MessageType: "incoming"}
assert.Equal(t, 1.0, messageScore(msg, ""))
})
t.Run("content match adds 3.0", func(t *testing.T) {
msg := &model.Message{Content: "test message", MessageType: "incoming"}
assert.Equal(t, 4.0, messageScore(msg, "test"))
})
t.Run("exact match adds 5.0 more", func(t *testing.T) {
msg := &model.Message{Content: "test", MessageType: "incoming"}
// 1.0 base + 3.0 contains + 5.0 exact = 9.0
assert.Equal(t, 9.0, messageScore(msg, "test"))
})
t.Run("outgoing adds 0.5", func(t *testing.T) {
msg := &model.Message{Content: "hello", MessageType: "outgoing"}
assert.Equal(t, 1.5, messageScore(msg, ""))
})
}
func TestContactSnippet(t *testing.T) {
t.Run("with all fields", func(t *testing.T) {
contact := &model.Contact{Base: model.Base{ID: 1}, Name: "Alice", Email: "alice@test.com", PhoneNumber: "+1234567890"}
assert.Equal(t, "Alice — alice@test.com — +1234567890", contactSnippet(contact, ""))
})
t.Run("with name only", func(t *testing.T) {
contact := &model.Contact{Base: model.Base{ID: 2}, Name: "Bob", Email: "", PhoneNumber: ""}
assert.Equal(t, "Bob", contactSnippet(contact, ""))
})
t.Run("with no fields", func(t *testing.T) {
contact := &model.Contact{Base: model.Base{ID: 3}, Name: "", Email: "", PhoneNumber: ""}
assert.Equal(t, "Contact #3", contactSnippet(contact, ""))
})
}
func TestContactScore(t *testing.T) {
t.Run("exact name match adds 5.0", func(t *testing.T) {
contact := &model.Contact{Name: "Alice", Email: "", PhoneNumber: ""}
assert.Equal(t, 6.0, contactScore(contact, "Alice"))
})
t.Run("partial name match adds 3.0", func(t *testing.T) {
contact := &model.Contact{Name: "Alice Smith", Email: "", PhoneNumber: ""}
assert.Equal(t, 4.0, contactScore(contact, "alice"))
})
t.Run("exact email match adds 5.0", func(t *testing.T) {
contact := &model.Contact{Name: "", Email: "alice@test.com", PhoneNumber: ""}
assert.Equal(t, 6.0, contactScore(contact, "alice@test.com"))
})
t.Run("partial email match adds 2.0", func(t *testing.T) {
contact := &model.Contact{Name: "", Email: "alice@test.com", PhoneNumber: ""}
assert.Equal(t, 3.0, contactScore(contact, "test.com"))
})
t.Run("phone number partial match adds 2.0", func(t *testing.T) {
contact := &model.Contact{Name: "", Email: "", PhoneNumber: "+1234567890"}
assert.Equal(t, 3.0, contactScore(contact, "123456"))
})
t.Run("no match gets base score", func(t *testing.T) {
contact := &model.Contact{Name: "Bob", Email: "bob@other.com", PhoneNumber: "+999"}
assert.Equal(t, 1.0, contactScore(contact, "alice"))
})
}
func TestArticleSnippet(t *testing.T) {
t.Run("uses description as snippet", func(t *testing.T) {
article := &model.Article{Base: model.Base{ID: 1}, Title: "FAQ", Description: "Common questions and answers"}
assert.Equal(t, "Common questions and answers", articleSnippet(article, ""))
})
t.Run("long description truncated", func(t *testing.T) {
longDesc := "This is a very long description that goes beyond the one hundred and twenty character limit for display in search results"
article := &model.Article{Base: model.Base{ID: 2}, Title: "Guide", Description: longDesc}
snippet := articleSnippet(article, "")
assert.True(t, strings.HasSuffix(snippet, "..."))
})
t.Run("fallback to title when no description", func(t *testing.T) {
article := &model.Article{Base: model.Base{ID: 3}, Title: "Getting Started", Description: ""}
assert.Equal(t, "Getting Started", articleSnippet(article, ""))
})
t.Run("fallback to Article #ID when no title", func(t *testing.T) {
article := &model.Article{Base: model.Base{ID: 5}, Title: "", Description: ""}
assert.Equal(t, "Article #5", articleSnippet(article, ""))
})
t.Run("description centered around query", func(t *testing.T) {
desc := "The quick brown fox jumps over the lazy dog while running through the forest and meadows in this very long article description that exceeds one hundred and twenty chars for testing"
article := &model.Article{Base: model.Base{ID: 10}, Description: desc}
snippet := articleSnippet(article, "fox")
assert.Contains(t, snippet, "fox")
assert.Contains(t, snippet, "...")
})
}
func TestArticleScore(t *testing.T) {
t.Run("base score without query", func(t *testing.T) {
article := &model.Article{Title: "Help", Description: "desc", Content: "content", Status: "draft"}
assert.Equal(t, 1.0, articleScore(article, ""))
})
t.Run("exact title match adds 5.0", func(t *testing.T) {
article := &model.Article{Title: "FAQ", Description: "", Content: "", Status: "draft"}
assert.Equal(t, 6.0, articleScore(article, "FAQ"))
})
t.Run("partial title match adds 3.0", func(t *testing.T) {
article := &model.Article{Title: "Getting Started Guide", Description: "", Content: "", Status: "draft"}
assert.Equal(t, 4.0, articleScore(article, "getting"))
})
t.Run("description match adds 2.0", func(t *testing.T) {
article := &model.Article{Title: "", Description: "Common questions about billing", Content: "", Status: "draft"}
assert.Equal(t, 3.0, articleScore(article, "billing"))
})
t.Run("content match adds 1.0", func(t *testing.T) {
article := &model.Article{Title: "", Description: "", Content: "Billing details are here", Status: "draft"}
assert.Equal(t, 2.0, articleScore(article, "billing"))
})
t.Run("published adds 1.5", func(t *testing.T) {
article := &model.Article{Title: "", Description: "", Content: "", Status: "published"}
assert.Equal(t, 2.5, articleScore(article, ""))
})
t.Run("combined title + desc + content + published", func(t *testing.T) {
article := &model.Article{Title: "FAQ", Description: "FAQ info", Content: "FAQ details", Status: "published"}
// 1.0 base + 5.0 exact title + 2.0 desc match + 1.0 content match + 1.5 published
// (Note: actual total may differ if scoring logic adds extra weight)
assert.Equal(t, 10.5, articleScore(article, "FAQ"))
})
}
// --- Sort tests ---
func TestSortResultsByScore(t *testing.T) {
results := []SearchResult{
{ID: 1, Score: 3.0, Type: ResultTypeConversation},
{ID: 2, Score: 7.0, Type: ResultTypeMessage},
{ID: 3, Score: 5.0, Type: ResultTypeContact},
}
sortResultsByScore(results)
assert.Equal(t, 7.0, results[0].Score)
assert.Equal(t, 5.0, results[1].Score)
assert.Equal(t, 3.0, results[2].Score)
}
func TestSortResultsByScore_Empty(t *testing.T) {
results := []SearchResult{}
sortResultsByScore(results)
assert.Empty(t, results)
}
func TestSortResultsByScore_Single(t *testing.T) {
results := []SearchResult{{ID: 1, Score: 5.0}}
sortResultsByScore(results)
assert.Len(t, results, 1)
assert.Equal(t, 5.0, results[0].Score)
}
func TestSortResultsByScore_StableOrder(t *testing.T) {
// Equal scores should retain insertion order (stable sort)
results := []SearchResult{
{ID: 1, Score: 5.0, Type: ResultTypeConversation},
{ID: 2, Score: 5.0, Type: ResultTypeMessage},
{ID: 3, Score: 5.0, Type: ResultTypeContact},
}
sortResultsByScore(results)
// All have same score, so order should remain stable
assert.Equal(t, uint(1), results[0].ID)
assert.Equal(t, uint(2), results[1].ID)
assert.Equal(t, uint(3), results[2].ID)
}