748 lines
30 KiB
Go
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())
|
|
})
|
|
}
|