779 lines
27 KiB
Go
779 lines
27 KiB
Go
package search
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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
|
|
convFilter *SearchFilter
|
|
|
|
messages []model.Message
|
|
msgCount int64
|
|
msgErr error
|
|
msgFilter *SearchFilter
|
|
|
|
contacts []model.Contact
|
|
contactCount int64
|
|
contactErr error
|
|
contactFilter *SearchFilter
|
|
|
|
companies []model.Company
|
|
companyCount int64
|
|
companyErr error
|
|
|
|
articles []model.Article
|
|
articleCount int64
|
|
articleErr error
|
|
articleFilter *SearchFilter
|
|
|
|
features map[string]bool
|
|
access SearchAccess
|
|
accessErr error
|
|
}
|
|
|
|
func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Conversation, int64, error) {
|
|
m.convFilter = filter
|
|
return m.conversations, m.convCount, m.convErr
|
|
}
|
|
|
|
func (m *mockSearchRepo) SearchMessages(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Message, int64, error) {
|
|
m.msgFilter = filter
|
|
return m.messages, m.msgCount, m.msgErr
|
|
}
|
|
|
|
func (m *mockSearchRepo) SearchContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Contact, int64, error) {
|
|
m.contactFilter = filter
|
|
return m.contacts, m.contactCount, m.contactErr
|
|
}
|
|
|
|
func (m *mockSearchRepo) AccountFeatureEnabled(ctx context.Context, accountID uint, flag string) bool {
|
|
return m.features[flag]
|
|
}
|
|
|
|
func (m *mockSearchRepo) SearchAccessForAccount(ctx context.Context, accountID, userID uint) (SearchAccess, error) {
|
|
return m.access, m.accessErr
|
|
}
|
|
|
|
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) {
|
|
m.articleFilter = filter
|
|
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_AppliesResolvedContactScope(t *testing.T) {
|
|
repo := &mockSearchRepo{}
|
|
repo.contacts = []model.Contact{makeContact(20, 1, "John Doe", "john@example.com", "")}
|
|
repo.contactCount = 1
|
|
|
|
svc := NewSearchService(repo)
|
|
filter := &SearchFilter{Page: 1, PerPage: 25, Types: []SearchResultType{ResultTypeContact}}
|
|
|
|
_, err := svc.GlobalSearch(context.Background(), 1, "john", filter)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, repo.contactFilter)
|
|
assert.True(t, repo.contactFilter.ContactResolvedScope)
|
|
assert.False(t, repo.contactFilter.ContactCRMV2)
|
|
}
|
|
|
|
func TestGlobalSearch_AppliesCRMV2ResolvedContactScope(t *testing.T) {
|
|
repo := &mockSearchRepo{features: map[string]bool{"crm_v2": true}}
|
|
repo.contacts = []model.Contact{makeContact(20, 1, "Lead", "", "")}
|
|
repo.contactCount = 1
|
|
|
|
svc := NewSearchService(repo)
|
|
filter := &SearchFilter{Page: 1, PerPage: 25, Types: []SearchResultType{ResultTypeContact}}
|
|
|
|
_, err := svc.GlobalSearch(context.Background(), 1, "lead", filter)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, repo.contactFilter)
|
|
assert.True(t, repo.contactFilter.ContactResolvedScope)
|
|
assert.True(t, repo.contactFilter.ContactCRMV2)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func TestGlobalSearch_AppliesReferenceFiltersPerResultType(t *testing.T) {
|
|
repo := &mockSearchRepo{features: map[string]bool{"advanced_search": true}}
|
|
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
|
|
senderID := uint(7)
|
|
inboxID := uint(5)
|
|
portalID := uint(2)
|
|
private := true
|
|
filter := &SearchFilter{
|
|
Page: 1,
|
|
PerPage: 15,
|
|
Types: []SearchResultType{ResultTypeConversation, ResultTypeMessage, ResultTypeContact, ResultTypeArticle},
|
|
Status: []string{"resolved"},
|
|
Priority: []string{"urgent"},
|
|
Labels: []string{"vip"},
|
|
InboxID: &inboxID,
|
|
SenderType: "agent",
|
|
SenderID: &senderID,
|
|
MessageType: "outgoing",
|
|
ContentType: "text",
|
|
Private: &private,
|
|
ContactSource: "email",
|
|
PortalID: &portalID,
|
|
ArticleStatus: "draft",
|
|
ArticleLocale: "fr",
|
|
}
|
|
|
|
svc := NewSearchService(repo)
|
|
_, err := svc.GlobalSearch(context.Background(), 1, "test", filter)
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, repo.convFilter)
|
|
assert.Empty(t, repo.convFilter.Status)
|
|
assert.Empty(t, repo.convFilter.Priority)
|
|
assert.Empty(t, repo.convFilter.Labels)
|
|
assert.Nil(t, repo.convFilter.InboxID)
|
|
require.NotNil(t, repo.msgFilter)
|
|
assert.Equal(t, "agent", repo.msgFilter.SenderType)
|
|
require.NotNil(t, repo.msgFilter.SenderID)
|
|
assert.Equal(t, senderID, *repo.msgFilter.SenderID)
|
|
require.NotNil(t, repo.msgFilter.InboxID)
|
|
assert.Equal(t, inboxID, *repo.msgFilter.InboxID)
|
|
assert.Empty(t, repo.msgFilter.MessageType)
|
|
assert.Empty(t, repo.msgFilter.ContentType)
|
|
assert.Nil(t, repo.msgFilter.Private)
|
|
require.NotNil(t, repo.contactFilter)
|
|
assert.Empty(t, repo.contactFilter.ContactSource)
|
|
require.NotNil(t, repo.articleFilter)
|
|
assert.Nil(t, repo.articleFilter.PortalID)
|
|
assert.Empty(t, repo.articleFilter.ArticleStatus)
|
|
assert.Empty(t, repo.articleFilter.ArticleLocale)
|
|
}
|
|
|
|
// --- 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 TestSearchMessages_AppliesCurrentUserInboxAccess(t *testing.T) {
|
|
userID := uint(7)
|
|
repo := &mockSearchRepo{
|
|
messages: []model.Message{makeMessage(10, 1, "Hello test message", "incoming")},
|
|
msgCount: 1,
|
|
access: SearchAccess{InboxIDs: []uint{3, 5}},
|
|
}
|
|
|
|
svc := NewSearchService(repo)
|
|
filter := &SearchFilter{Page: 1, PerPage: 25, CurrentUserID: &userID}
|
|
|
|
_, _, err := svc.SearchMessages(context.Background(), 1, "test", filter)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, repo.msgFilter)
|
|
assert.True(t, repo.msgFilter.EnforceInboxAccess)
|
|
assert.Equal(t, []uint{3, 5}, repo.msgFilter.AccessibleInboxIDs)
|
|
}
|
|
|
|
func TestSearchMessages_AppliesThreeMonthMessageBase(t *testing.T) {
|
|
fixedNow := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
|
originalNow := searchNow
|
|
searchNow = func() time.Time { return fixedNow }
|
|
t.Cleanup(func() { searchNow = originalNow })
|
|
repo := &mockSearchRepo{
|
|
messages: []model.Message{makeMessage(10, 1, "Hello test message", "incoming")},
|
|
msgCount: 1,
|
|
}
|
|
|
|
svc := NewSearchService(repo)
|
|
_, _, err := svc.SearchMessages(context.Background(), 1, "test", &SearchFilter{Page: 1, PerPage: 25})
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, repo.msgFilter)
|
|
if assert.NotNil(t, repo.msgFilter.MessageCreatedAfter) {
|
|
assert.Equal(t, fixedNow.AddDate(0, -3, 0), *repo.msgFilter.MessageCreatedAfter)
|
|
}
|
|
}
|
|
|
|
func TestSearchMessages_AppliesAdvancedSearchFeatureFlag(t *testing.T) {
|
|
repo := &mockSearchRepo{
|
|
messages: []model.Message{makeMessage(10, 1, "Hello test message", "incoming")},
|
|
msgCount: 1,
|
|
features: map[string]bool{"advanced_search": true},
|
|
}
|
|
svc := NewSearchService(repo)
|
|
_, _, err := svc.SearchMessages(context.Background(), 1, "test", &SearchFilter{Page: 1, PerPage: 25})
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, repo.msgFilter)
|
|
assert.True(t, repo.msgFilter.AdvancedSearchEnabled)
|
|
}
|
|
|
|
func TestSearchMessages_CapsAdvancedSearchDateRange(t *testing.T) {
|
|
fixedNow := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
|
originalNow := searchNow
|
|
searchNow = func() time.Time { return fixedNow }
|
|
t.Cleanup(func() { searchNow = originalNow })
|
|
repo := &mockSearchRepo{
|
|
messages: []model.Message{makeMessage(10, 1, "Hello test message", "incoming")},
|
|
msgCount: 1,
|
|
features: map[string]bool{"advanced_search": true},
|
|
}
|
|
svc := NewSearchService(repo)
|
|
since := fixedNow.Add(-120 * 24 * time.Hour)
|
|
until := fixedNow.Add(120 * 24 * time.Hour)
|
|
|
|
_, _, err := svc.SearchMessages(context.Background(), 1, "test", &SearchFilter{Page: 1, PerPage: 25, DateFrom: &since, DateTo: &until})
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, repo.msgFilter)
|
|
if assert.NotNil(t, repo.msgFilter.DateFrom) {
|
|
assert.Equal(t, fixedNow.Add(-90*24*time.Hour), *repo.msgFilter.DateFrom)
|
|
}
|
|
if assert.NotNil(t, repo.msgFilter.DateTo) {
|
|
assert.Equal(t, fixedNow.Add(90*24*time.Hour), *repo.msgFilter.DateTo)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|