Files
gochat/internal/repository/search_repo_test.go
T

748 lines
30 KiB
Go

package repository
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/search"
)
// ========== Helper functions ==========
func createTestAccountForSearch(t *testing.T, db *gorm.DB) *model.Account {
t.Helper()
account := &model.Account{Name: "SearchTestAccount", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
return account
}
func createTestConversationForSearch(t *testing.T, db *gorm.DB, accountID uint, status, labels string) *model.Conversation {
t.Helper()
conv := &model.Conversation{
AccountID: accountID,
Status: status,
Labels: labels,
}
require.NoError(t, db.Create(conv).Error)
return conv
}
func createTestMessageForSearch(t *testing.T, db *gorm.DB, accountID, conversationID uint, content, msgType string) *model.Message {
t.Helper()
msg := &model.Message{
AccountID: accountID,
ConversationID: conversationID,
Content: content,
MessageType: msgType,
}
require.NoError(t, db.Create(msg).Error)
return msg
}
func createTestInboxForSearch(t *testing.T, db *gorm.DB, accountID uint, name string) *model.Inbox {
t.Helper()
inbox := &model.Inbox{AccountID: accountID, Name: name, ChannelType: "web_widget", ChannelID: 1}
require.NoError(t, db.Create(inbox).Error)
return inbox
}
func createTestUserForSearch(t *testing.T, db *gorm.DB, accountID uint, email string) *model.User {
t.Helper()
user := &model.User{AccountID: accountID, Name: email, Email: email, Password: "password", Active: true}
require.NoError(t, db.Create(user).Error)
return user
}
func createTestContactForSearch(t *testing.T, db *gorm.DB, accountID uint, name, email, phone string) *model.Contact {
t.Helper()
contact := &model.Contact{
AccountID: accountID,
Name: name,
Email: email,
PhoneNumber: phone,
}
require.NoError(t, db.Create(contact).Error)
return contact
}
func createTestArticleForSearch(t *testing.T, db *gorm.DB, accountID, portalID uint, title, description, content, status string) *model.Article {
t.Helper()
article := &model.Article{
AccountID: accountID,
PortalID: portalID,
Title: title,
Slug: strings.ToLower(strings.ReplaceAll(title, " ", "-")),
Description: description,
Content: content,
Status: status,
}
require.NoError(t, db.Create(article).Error)
return article
}
// ========== RepoSearchFilter unit tests ==========
func TestRepoSearchFilter_Offset(t *testing.T) {
f := &RepoSearchFilter{Page: 2, PerPage: 10}
assert.Equal(t, 10, f.Offset())
f2 := &RepoSearchFilter{Page: 1, PerPage: 25}
assert.Equal(t, 0, f2.Offset())
}
func TestRepoSearchFilter_OrderClause(t *testing.T) {
t.Run("defaults", func(t *testing.T) {
f := &RepoSearchFilter{}
assert.Equal(t, "created_at desc", f.OrderClause())
})
t.Run("explicit sort", func(t *testing.T) {
f := &RepoSearchFilter{SortBy: "updated_at", SortOrder: "asc"}
assert.Equal(t, "updated_at asc", f.OrderClause())
})
t.Run("priority sort", func(t *testing.T) {
f := &RepoSearchFilter{SortBy: "priority", SortOrder: "desc"}
assert.Equal(t, "priority desc", f.OrderClause())
})
}
// ========== searchFilterToRepo conversion tests ==========
func TestSearchFilterToRepo_NilFilter(t *testing.T) {
result := searchFilterToRepo(nil)
assert.Equal(t, 1, result.Page)
assert.Equal(t, 25, result.PerPage)
assert.Equal(t, "created_at", result.SortBy)
assert.Equal(t, "desc", result.SortOrder)
}
func TestSearchFilterToRepo_PreservesFields(t *testing.T) {
assigneeID := uint(5)
teamID := uint(10)
private := true
sf := &search.SearchFilter{
Status: []string{"open"},
Priority: []string{"urgent"},
AssigneeID: &assigneeID,
TeamID: &teamID,
SortBy: "updated_at",
SortOrder: "asc",
Page: 3,
PerPage: 15,
Private: &private,
ContactSource: "api",
MessageType: "incoming",
}
result := searchFilterToRepo(sf)
assert.Equal(t, []string{"open"}, result.Status)
assert.Equal(t, []string{"urgent"}, result.Priority)
assert.Equal(t, uint(5), *result.AssigneeID)
assert.Equal(t, uint(10), *result.TeamID)
assert.Equal(t, "updated_at", result.SortBy)
assert.Equal(t, "asc", result.SortOrder)
assert.Equal(t, 3, result.Page)
assert.Equal(t, 15, result.PerPage)
assert.True(t, *result.Private)
assert.Equal(t, "api", result.ContactSource)
assert.Equal(t, "incoming", result.MessageType)
}
func TestSearchRepo_SearchAccessForAccountScopesAssignedInboxes(t *testing.T) {
db := setupTestDB(t, &model.InboxMember{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
user := createTestUserForSearch(t, db, account.ID, "agent-search@example.com")
assigned := createTestInboxForSearch(t, db, account.ID, "Assigned")
_ = createTestInboxForSearch(t, db, account.ID, "Hidden")
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: assigned.ID, UserID: user.ID}).Error)
access, err := repo.SearchAccessForAccount(context.Background(), account.ID, user.ID)
require.NoError(t, err)
assert.False(t, access.SkipInboxFiltering)
assert.Equal(t, []uint{assigned.ID}, access.InboxIDs)
}
func TestSearchRepo_SearchAccessForAccountSkipsForAdministratorAndAllInboxAgents(t *testing.T) {
db := setupTestDB(t, &model.InboxMember{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
admin := createTestUserForSearch(t, db, account.ID, "admin-search@example.com")
agent := createTestUserForSearch(t, db, account.ID, "all-inbox-search@example.com")
inbox1 := createTestInboxForSearch(t, db, account.ID, "Inbox 1")
inbox2 := createTestInboxForSearch(t, db, account.ID, "Inbox 2")
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: admin.ID, Role: "administrator"}).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: agent.ID, Role: "agent"}).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox1.ID, UserID: agent.ID}).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox2.ID, UserID: agent.ID}).Error)
adminAccess, err := repo.SearchAccessForAccount(context.Background(), account.ID, admin.ID)
require.NoError(t, err)
assert.True(t, adminAccess.SkipInboxFiltering)
agentAccess, err := repo.SearchAccessForAccount(context.Background(), account.ID, agent.ID)
require.NoError(t, err)
assert.True(t, agentAccess.SkipInboxFiltering)
}
func TestSearchRepo_SearchMessages_InboxAccessRestriction(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
visibleInbox := createTestInboxForSearch(t, db, account.ID, "Visible")
hiddenInbox := createTestInboxForSearch(t, db, account.ID, "Hidden")
visibleConv := createTestConversationForSearch(t, db, account.ID, "open", "")
hiddenConv := createTestConversationForSearch(t, db, account.ID, "open", "")
require.NoError(t, db.Model(visibleConv).Update("inbox_id", visibleInbox.ID).Error)
require.NoError(t, db.Model(hiddenConv).Update("inbox_id", hiddenInbox.ID).Error)
visibleMsg := createTestMessageForSearch(t, db, account.ID, visibleConv.ID, "visible message", "incoming")
require.NoError(t, db.Model(visibleMsg).Update("inbox_id", visibleInbox.ID).Error)
hiddenMsg := createTestMessageForSearch(t, db, account.ID, hiddenConv.ID, "hidden message", "incoming")
require.NoError(t, db.Model(hiddenMsg).Update("inbox_id", hiddenInbox.ID).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{visibleInbox.ID}}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, visibleMsg.ID, results[0].ID)
}
func TestSearchRepo_SearchMessages_InaccessibleInboxParamFallsBackToAccessibleBase(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
visibleInbox := createTestInboxForSearch(t, db, account.ID, "Visible")
hiddenInbox := createTestInboxForSearch(t, db, account.ID, "Hidden")
visibleConv := createTestConversationForSearch(t, db, account.ID, "open", "")
hiddenConv := createTestConversationForSearch(t, db, account.ID, "open", "")
require.NoError(t, db.Model(visibleConv).Update("inbox_id", visibleInbox.ID).Error)
require.NoError(t, db.Model(hiddenConv).Update("inbox_id", hiddenInbox.ID).Error)
visibleMsg := createTestMessageForSearch(t, db, account.ID, visibleConv.ID, "visible message", "incoming")
require.NoError(t, db.Model(visibleMsg).Update("inbox_id", visibleInbox.ID).Error)
hiddenMsg := createTestMessageForSearch(t, db, account.ID, hiddenConv.ID, "hidden message", "incoming")
require.NoError(t, db.Model(hiddenMsg).Update("inbox_id", hiddenInbox.ID).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, InboxID: &hiddenInbox.ID, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{visibleInbox.ID}}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, visibleMsg.ID, results[0].ID)
}
func TestSearchRepo_SearchMessages_MessageBaseExcludesOldMessages(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
conv := createTestConversationForSearch(t, db, account.ID, "open", "")
cutoff := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
recentMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "recent message", "incoming")
require.NoError(t, db.Model(recentMsg).Update("created_at", cutoff.Add(time.Hour)).Error)
oldMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "old message", "incoming")
require.NoError(t, db.Model(oldMsg).Update("created_at", cutoff.Add(-time.Hour)).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, MessageCreatedAfter: &cutoff}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, recentMsg.ID, results[0].ID)
}
func TestSearchRepo_SearchMessages_SenderFilterRequiresAdvancedSearch(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
conv := createTestConversationForSearch(t, db, account.ID, "open", "")
contactID := uint(42)
userID := uint(77)
contactMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "contact message", "incoming")
require.NoError(t, db.Model(contactMsg).Updates(map[string]any{"sender_type": "contact", "sender_id": contactID}).Error)
userMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "user message", "outgoing")
require.NoError(t, db.Model(userMsg).Updates(map[string]any{"sender_type": "User", "sender_id": userID}).Error)
baseFilter := &search.SearchFilter{Page: 1, PerPage: 25, SenderType: "contact", SenderID: &contactID}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", baseFilter)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, results, 2)
advancedFilter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, SenderType: "contact", SenderID: &contactID}
results, total, err = repo.SearchMessages(context.Background(), account.ID, "", advancedFilter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, contactMsg.ID, results[0].ID)
}
func TestSearchRepo_SearchMessages_AgentFromFilterMatchesStoredUserSenderTypes(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
conv := createTestConversationForSearch(t, db, account.ID, "open", "")
userID := uint(77)
upperUserMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "upper user message", "outgoing")
require.NoError(t, db.Model(upperUserMsg).Updates(map[string]any{"sender_type": "User", "sender_id": userID}).Error)
lowerUserMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "lower user message", "outgoing")
require.NoError(t, db.Model(lowerUserMsg).Updates(map[string]any{"sender_type": "user", "sender_id": userID}).Error)
contactMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "contact message", "incoming")
require.NoError(t, db.Model(contactMsg).Updates(map[string]any{"sender_type": "contact", "sender_id": userID}).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, SenderType: "agent", SenderID: &userID}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
require.Len(t, results, 2)
ids := []uint{results[0].ID, results[1].ID}
assert.Contains(t, ids, upperUserMsg.ID)
assert.Contains(t, ids, lowerUserMsg.ID)
assert.NotContains(t, ids, contactMsg.ID)
}
// ========== SearchConversations DB tests (PG only for ILIKE) ==========
func TestSearchRepo_SearchConversations_EmptyQuery(t *testing.T) {
skipIfSQLite(t) // ILIKE requires PostgreSQL
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
conv := createTestConversationForSearch(t, db, account.ID, "open", "support,billing")
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "", filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
found := false
for _, c := range results {
if c.ID == conv.ID {
found = true
}
}
assert.True(t, found, "expected conversation %d in results", conv.ID)
}
func TestSearchRepo_SearchConversations_PreloadsChatwootPayloadRelations(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
contact := createTestContactForSearch(t, db, account.ID, "Ada Lovelace", "ada@example.com", "+123")
inbox := createTestInboxForSearch(t, db, account.ID, "Website")
agent := createTestUserForSearch(t, db, account.ID, "agent-payload@example.com")
conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &agent.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
require.NoError(t, db.Create(conv).Error)
newer := createTestMessageForSearch(t, db, account.ID, conv.ID, "newer message", "incoming")
older := createTestMessageForSearch(t, db, account.ID, conv.ID, "older message", "outgoing")
require.NoError(t, db.Model(older).Update("created_at", time.Date(2026, 6, 7, 8, 0, 0, 0, time.UTC)).Error)
require.NoError(t, db.Model(newer).Update("created_at", time.Date(2026, 6, 7, 9, 0, 0, 0, time.UTC)).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
result := results[0]
require.NotNil(t, result.Contact)
assert.Equal(t, contact.Name, result.Contact.Name)
require.NotNil(t, result.Inbox)
assert.Equal(t, inbox.Name, result.Inbox.Name)
require.NotNil(t, result.Assignee)
assert.Equal(t, agent.Email, result.Assignee.Email)
require.Len(t, result.Messages, 2)
assert.Equal(t, older.ID, result.Messages[0].ID)
}
func TestSearchRepo_SearchConversations_DisplayAndContactMatch(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
contact := createTestContactForSearch(t, db, account.ID, "Ada Lovelace", "ada@example.com", "+123")
otherContact := createTestContactForSearch(t, db, account.ID, "Grace Hopper", "grace@example.com", "+456")
match := createTestConversationForSearch(t, db, account.ID, "open", "support,billing")
displayID := uint(4242)
require.NoError(t, db.Model(match).Updates(map[string]any{"contact_id": contact.ID, "display_id": displayID}).Error)
other := createTestConversationForSearch(t, db, account.ID, "resolved", "ada-label")
require.NoError(t, db.Model(other).Update("contact_id", otherContact.ID).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "ada", filter)
assert.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, match.ID, results[0].ID)
results, total, err = repo.SearchConversations(context.Background(), account.ID, "424", filter)
assert.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, match.ID, results[0].ID)
}
func TestSearchRepo_SearchConversations_DoesNotMatchLabelsOrMessages(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
contact := createTestContactForSearch(t, db, account.ID, "No Match", "nomatch@example.com", "")
conv := createTestConversationForSearch(t, db, account.ID, "open", "billing")
require.NoError(t, db.Model(conv).Update("contact_id", contact.ID).Error)
_ = createTestMessageForSearch(t, db, account.ID, conv.ID, "billing message", "incoming")
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "billing", filter)
require.NoError(t, err)
assert.Equal(t, int64(0), total)
assert.Empty(t, results)
}
func TestSearchRepo_SearchConversations_DefaultOrderUsesCreatedAt(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
older := createTestConversationForSearch(t, db, account.ID, "open", "")
newer := createTestConversationForSearch(t, db, account.ID, "open", "")
base := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
olderActivity := base.Add(time.Hour).Unix()
newerActivity := base.Add(-time.Hour).Unix()
require.NoError(t, db.Model(older).Updates(map[string]any{"created_at": base.Add(-time.Hour), "last_activity_at": olderActivity}).Error)
require.NoError(t, db.Model(newer).Updates(map[string]any{"created_at": base.Add(time.Hour), "last_activity_at": newerActivity}).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
require.Len(t, results, 2)
assert.Equal(t, newer.ID, results[0].ID)
assert.Equal(t, older.ID, results[1].ID)
}
func TestSearchRepo_SearchConversations_WithStatusFilter(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
_ = createTestConversationForSearch(t, db, account.ID, "open", "")
_ = createTestConversationForSearch(t, db, account.ID, "resolved", "")
filter := &search.SearchFilter{Status: []string{"open"}, Page: 1, PerPage: 25}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "", filter)
assert.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, "open", results[0].Status)
}
func TestSearchRepo_SearchConversations_DateFilterUsesLastActivityAt(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
cutoff := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
oldActivity := cutoff.Add(-time.Hour).Unix()
recentActivity := cutoff.Add(time.Hour).Unix()
oldConv := createTestConversationForSearch(t, db, account.ID, "open", "date-filter")
recentConv := createTestConversationForSearch(t, db, account.ID, "open", "date-filter")
require.NoError(t, db.Model(oldConv).Update("last_activity_at", oldActivity).Error)
require.NoError(t, db.Model(recentConv).Update("last_activity_at", recentActivity).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, DateFrom: &cutoff}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, recentConv.ID, results[0].ID)
}
// ========== SearchMessages DB tests (PG only for ILIKE) ==========
func TestSearchRepo_SearchMessages_ContentMatch(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
conv := createTestConversationForSearch(t, db, account.ID, "open", "")
_ = createTestMessageForSearch(t, db, account.ID, conv.ID, "hello world", "incoming")
_ = createTestMessageForSearch(t, db, account.ID, conv.ID, "goodbye moon", "incoming")
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "world", filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
assert.NotEmpty(t, results)
}
func TestSearchRepo_SearchMessages_EmptyQuery(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
conv := createTestConversationForSearch(t, db, account.ID, "open", "")
msg := createTestMessageForSearch(t, db, account.ID, conv.ID, "hello world", "incoming")
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
found := false
for _, m := range results {
if m.ID == msg.ID {
found = true
}
}
assert.True(t, found, "expected message %d in results", msg.ID)
}
// ========== SearchContacts DB tests (PG only for ILIKE) ==========
func TestSearchRepo_SearchContacts_NameMatch(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
_ = createTestContactForSearch(t, db, account.ID, "Alice Smith", "alice@example.com", "")
_ = createTestContactForSearch(t, db, account.ID, "Bob Jones", "bob@example.com", "")
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchContacts(context.Background(), account.ID, "Alice", filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
assert.NotEmpty(t, results)
}
func TestSearchRepo_SearchContacts_EmailMatch(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
_ = createTestContactForSearch(t, db, account.ID, "Alice Smith", "alice@example.com", "")
_ = createTestContactForSearch(t, db, account.ID, "Bob Jones", "bob@example.com", "")
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchContacts(context.Background(), account.ID, "bob@example", filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
assert.NotEmpty(t, results)
}
func TestSearchRepo_SearchContacts_ResolvedScope(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
resolved := createTestContactForSearch(t, db, account.ID, "Scope Resolved", "scope@example.com", "")
unresolved := createTestContactForSearch(t, db, account.ID, "Scope Anonymous", "", "")
filter := &search.SearchFilter{Page: 1, PerPage: 25, ContactResolvedScope: true}
results, total, err := repo.SearchContacts(context.Background(), account.ID, "Scope", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, resolved.ID, results[0].ID)
assert.NotEqual(t, unresolved.ID, results[0].ID)
}
func TestSearchRepo_SearchContacts_ResolvedScopeCRMV2(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
require.NoError(t, db.Model(account).Update("feature_flags", `{"crm_v2":true}`).Error)
lead := createTestContactForSearch(t, db, account.ID, "Scope Lead", "", "")
require.NoError(t, db.Model(lead).Update("contact_type", "lead").Error)
customer := createTestContactForSearch(t, db, account.ID, "Scope Customer", "customer@example.com", "")
require.NoError(t, db.Model(customer).Update("contact_type", "customer").Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, ContactResolvedScope: true, ContactCRMV2: repo.AccountFeatureEnabled(context.Background(), account.ID, "crm_v2")}
results, total, err := repo.SearchContacts(context.Background(), account.ID, "Scope", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, lead.ID, results[0].ID)
}
func TestSearchRepo_SearchContacts_DateFilterUsesLastActivityAt(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
cutoff := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
oldActivity := cutoff.Add(-time.Hour).Unix()
recentActivity := cutoff.Add(time.Hour).Unix()
oldContact := createTestContactForSearch(t, db, account.ID, "Old Activity", "old@example.com", "")
recentContact := createTestContactForSearch(t, db, account.ID, "Recent Activity", "recent@example.com", "")
require.NoError(t, db.Model(oldContact).Update("last_activity_at", oldActivity).Error)
require.NoError(t, db.Model(recentContact).Update("last_activity_at", recentActivity).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, DateFrom: &cutoff}
results, total, err := repo.SearchContacts(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, recentContact.ID, results[0].ID)
}
// ========== SearchArticles DB tests (PG only for ILIKE) ==========
func TestSearchRepo_SearchArticles_TitleMatch(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t, &model.Article{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
_ = createTestArticleForSearch(t, db, account.ID, 1, "Getting Started Guide", "How to get started", "Full guide content here", "published")
_ = createTestArticleForSearch(t, db, account.ID, 1, "FAQ", "Common questions", "FAQ content", "published")
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchArticles(context.Background(), account.ID, "Guide", filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
assert.NotEmpty(t, results)
}
func TestSearchRepo_SearchArticles_WithStatusFilter(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t, &model.Article{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
_ = createTestArticleForSearch(t, db, account.ID, 1, "Published Article", "desc", "content", "published")
_ = createTestArticleForSearch(t, db, account.ID, 1, "Draft Article", "desc2", "content2", "draft")
filter := &search.SearchFilter{ArticleStatus: "published", Page: 1, PerPage: 25}
results, total, err := repo.SearchArticles(context.Background(), account.ID, "", filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
for _, a := range results {
assert.Equal(t, "published", a.Status)
}
}
func TestSearchRepo_SearchArticles_DateFilterUsesUpdatedAt(t *testing.T) {
db := setupTestDB(t, &model.Article{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
cutoff := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
oldArticle := createTestArticleForSearch(t, db, account.ID, 1, "Old Updated Article", "desc", "content", "published")
recentArticle := createTestArticleForSearch(t, db, account.ID, 1, "Recent Updated Article", "desc", "content", "published")
require.NoError(t, db.Model(oldArticle).UpdateColumn("updated_at", cutoff.Add(-time.Hour)).Error)
require.NoError(t, db.Model(recentArticle).UpdateColumn("updated_at", cutoff.Add(time.Hour)).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, DateFrom: &cutoff}
results, total, err := repo.SearchArticles(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, recentArticle.ID, results[0].ID)
}
// ========== Interface satisfaction test ==========
func TestSearchRepo_SatisfiesSearchRepoInterface(t *testing.T) {
// Verify SearchRepo satisfies SearchRepoInterface at compile time
var _ search.SearchRepoInterface = (*SearchRepo)(nil)
}
// ========== SearchMode / SearchFilter tests ==========
func TestSearchFilter_SearchMode_Default(t *testing.T) {
sf := &search.SearchFilter{Page: 1, PerPage: 25}
// SearchMode zero value is "" when struct is created directly.
// Default SearchModeILike is only set via ParseSearchFilter or explicit assignment.
assert.Equal(t, search.SearchMode(""), sf.SearchMode)
}
func TestSearchFilter_SearchMode_Trigram(t *testing.T) {
sf := &search.SearchFilter{SearchMode: search.SearchModeTrigram, Page: 1, PerPage: 25}
assert.True(t, sf.IsTrigram())
}
func TestSearchFilterToRepo_SearchMode_Trigram(t *testing.T) {
sf := &search.SearchFilter{SearchMode: search.SearchModeTrigram, Page: 1, PerPage: 25}
result := searchFilterToRepo(sf)
assert.Equal(t, search.SearchModeTrigram, result.SearchMode)
assert.True(t, result.IsTrigram())
}
func TestSearchFilterToRepo_SearchMode_Default(t *testing.T) {
sf := &search.SearchFilter{Page: 1, PerPage: 25} // SearchMode defaults to ilike
result := searchFilterToRepo(sf)
assert.Equal(t, search.SearchModeILike, result.SearchMode)
assert.False(t, result.IsTrigram())
}
func TestRepoSearchFilter_IsTrigram(t *testing.T) {
t.Run("ilike mode", func(t *testing.T) {
f := &RepoSearchFilter{SearchMode: search.SearchModeILike}
assert.False(t, f.IsTrigram())
})
t.Run("trigram mode", func(t *testing.T) {
f := &RepoSearchFilter{SearchMode: search.SearchModeTrigram}
assert.True(t, f.IsTrigram())
})
t.Run("empty mode defaults to ilike", func(t *testing.T) {
f := &RepoSearchFilter{}
assert.False(t, f.IsTrigram())
})
}
// ========== ParseSearchFilter search_mode tests ==========
func TestSearchModeConstants(t *testing.T) {
assert.Equal(t, search.SearchMode("ilike"), search.SearchModeILike)
assert.Equal(t, search.SearchMode("trigram"), search.SearchModeTrigram)
}
func TestSearchFilter_IsTrigram_Unit(t *testing.T) {
t.Run("empty filter defaults to ilike (not trigram)", func(t *testing.T) {
sf := &search.SearchFilter{}
assert.False(t, sf.IsTrigram())
})
t.Run("explicit ilike mode", func(t *testing.T) {
sf := &search.SearchFilter{SearchMode: search.SearchModeILike}
assert.False(t, sf.IsTrigram())
})
t.Run("explicit trigram mode", func(t *testing.T) {
sf := &search.SearchFilter{SearchMode: search.SearchModeTrigram}
assert.True(t, sf.IsTrigram())
})
}