package search import ( "context" "encoding/json" "io" "net/http" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gochat/gochat/internal/model" ) type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func jsonResponse(status int, body string) *http.Response { return &http.Response{ StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body)), } } func TestNewSearchEngine_DefaultsToMeilisearch(t *testing.T) { engine, err := NewSearchEngine(EngineConfig{}, nil) require.NoError(t, err) _, ok := engine.(*MeiliSearchEngine) assert.True(t, ok) } func TestNewSearchEngine_DBFallbackRequiresRepo(t *testing.T) { engine, err := NewSearchEngine(EngineConfig{Engine: EngineDB}, nil) assert.Error(t, err) assert.Nil(t, engine) } func TestDocumentBuildersSetStableUIDAndType(t *testing.T) { lastActivity := int64(1772884800) conv := makeConversation(12, 3, "open", "billing,urgent") conv.InboxID = 7 conv.ContactID = 9 conv.LastActivityAt = &lastActivity displayID := uint(42) conv.DisplayID = &displayID conv.Contact = &model.Contact{Name: "Ada Lovelace", Email: "ada@example.com", PhoneNumber: "+123", Identifier: "ada-id"} conv.Inbox = &model.Inbox{Base: model.Base{ID: 7}, Name: "Website", ChannelID: 11, ChannelType: "web_widget"} conv.Assignee = &model.User{Base: model.Base{ID: 8}, Name: "Agent One", Email: "agent@example.com", Role: "administrator"} conv.Messages = []model.Message{{Base: model.Base{ID: 15, CreatedAt: time.Unix(1772884700, 0)}, AccountID: 3, InboxID: 7, ConversationID: 12, Content: "hello", MessageType: "incoming"}} doc := ConversationDocument(conv) assert.Equal(t, "3_conversation_12", doc.UID) assert.Equal(t, ResultTypeConversation, doc.Type) assert.Equal(t, uint(3), doc.AccountID) assert.Equal(t, []string{"billing", "urgent"}, doc.Labels) assert.Equal(t, lastActivity, doc.LastActivityAtTS) assert.Equal(t, "Conversation #42", doc.Title) assert.Equal(t, "42 Ada Lovelace ada@example.com +123 ada-id", doc.Content) assert.Equal(t, "Ada Lovelace", doc.Data["contact"].(map[string]interface{})["name"]) assert.Equal(t, "Website", doc.Data["inbox"].(map[string]interface{})["name"]) assert.Equal(t, "Agent One", doc.Data["agent"].(map[string]interface{})["available_name"]) assert.Equal(t, uint(15), doc.Data["message"].(map[string]interface{})["id"]) } func TestMessageDocumentCarriesChatwootPayloadData(t *testing.T) { displayID := uint(42) msg := model.Message{ Base: model.Base{ID: 15, CreatedAt: time.Unix(1772884700, 0)}, AccountID: 3, InboxID: 7, ConversationID: 12, Content: "hello with file", MessageType: "incoming", ContentType: "text", Status: "sent", Conversation: &model.Conversation{Base: model.Base{ID: 12}, DisplayID: &displayID}, Attachments: []model.Attachment{{ Base: model.Base{ID: 99}, AccountID: 3, MessageID: 15, FileType: "audio", FileName: "note.mp3", FileURL: "https://files.example/note.mp3", Metadata: `{"transcribed_text":"hello transcript"}`, }}, } doc := MessageDocument(msg) message := doc.Data["message"].(map[string]interface{}) assert.Equal(t, displayID, message["conversation_id"]) attachments := message["attachments"].([]map[string]interface{}) require.Len(t, attachments, 1) assert.Equal(t, "audio", attachments[0]["file_type"]) assert.Equal(t, "hello transcript", attachments[0]["transcribed_text"]) } func TestArticleDocumentCarriesSearchPartialData(t *testing.T) { categoryID := uint(9) article := model.Article{ Base: model.Base{ID: 21, UpdatedAt: time.Unix(1772884800, 0)}, AccountID: 3, PortalID: 5, CategoryID: &categoryID, Title: "Billing FAQ", Slug: "billing-faq", Locale: "en", Content: "billing content", Status: "published", Portal: model.Portal{Base: model.Base{ID: 5}, Slug: "help-center"}, Category: &model.Category{Base: model.Base{ID: categoryID}, Name: "Billing"}, } doc := ArticleDocument(article) payload := doc.Data["article"].(map[string]interface{}) assert.Equal(t, "help-center", payload["portal_slug"]) assert.Equal(t, "Billing", payload["category_name"]) assert.Equal(t, int64(1772884800), payload["updated_at"]) } func TestContactDocumentSetsResolvedScopeFields(t *testing.T) { doc := ContactDocument(model.Contact{ Base: model.Base{ID: 5}, AccountID: 2, Name: "Grace Hopper", Email: "grace@example.com", ContactType: "lead", }) assert.Equal(t, ResultTypeContact, doc.Type) assert.Equal(t, "lead", doc.ContactType) assert.Equal(t, "lead", doc.ContactSource) assert.True(t, doc.ContactHasDetails) labelled := ContactDocument(model.Contact{Base: model.Base{ID: 7}, AccountID: 2, Name: "Labelled", Labels: []string{"vip", "trial"}}) assert.Equal(t, []string{"vip", "trial"}, labelled.Labels) anonymous := ContactDocument(model.Contact{Base: model.Base{ID: 6}, AccountID: 2, Name: "Anonymous"}) assert.False(t, anonymous.ContactHasDetails) } func TestMeiliSearchEngine_SearchSendsScopedFilter(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { require.Equal(t, "/indexes/gochat_contacts/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{ "hits":[{"uid":"42_contact_9","id":9,"type":"contact","account_id":42,"snippet":"Ada Lovelace","_rankingScore":0.98}], "estimatedTotalHits":1 }`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeContact}} resp, err := engine.Search(context.Background(), 42, "ada", filter) require.NoError(t, err) assert.Equal(t, int64(1), resp.TotalCount) assert.Len(t, resp.Results, 1) assert.Equal(t, uint(9), resp.Results[0].ID) assert.Equal(t, "account_id = 42", requestBody["filter"]) assert.Equal(t, "ada", requestBody["q"]) } func TestMeiliSearchEngine_SearchSendsResolvedContactFilter(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { require.Equal(t, "/indexes/gochat_contacts/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeContact}, ContactResolvedScope: true} _, err := engine.Search(context.Background(), 42, "ada", filter) require.NoError(t, err) assert.Equal(t, "account_id = 42 AND contact_has_details = true", requestBody["filter"]) } func TestMeiliSearchEngine_SearchSendsCRMV2ResolvedContactFilter(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { require.Equal(t, "/indexes/gochat_contacts/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeContact}, ContactResolvedScope: true, ContactCRMV2: true} _, err := engine.Search(context.Background(), 42, "ada", filter) require.NoError(t, err) assert.Equal(t, `account_id = 42 AND contact_type = "lead"`, requestBody["filter"]) } func TestMeiliSearchEngine_SearchSendsMessageSenderIDFilter(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) senderID := uint(77) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, AdvancedSearchEnabled: true, SenderType: "contact", SenderID: &senderID} _, err := engine.Search(context.Background(), 42, "hello", filter) require.NoError(t, err) assert.Equal(t, "account_id = 42 AND (sender_type = \"contact\" OR sender_type = \"Contact\") AND sender_id = 77", requestBody["filter"]) } func TestMeiliSearchEngine_SearchSendsAgentSenderAliases(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) senderID := uint(77) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, AdvancedSearchEnabled: true, SenderType: "agent", SenderID: &senderID} _, err := engine.Search(context.Background(), 42, "hello", filter) require.NoError(t, err) assert.Equal(t, "account_id = 42 AND (sender_type = \"agent\" OR sender_type = \"user\" OR sender_type = \"User\") AND sender_id = 77", requestBody["filter"]) } func TestMeiliSearchEngine_SearchSkipsAdvancedFiltersWhenFeatureDisabled(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) senderID := uint(77) inboxID := uint(9) dateFrom := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, SenderType: "contact", SenderID: &senderID, InboxID: &inboxID, DateFrom: &dateFrom} _, err := engine.Search(context.Background(), 42, "hello", filter) require.NoError(t, err) assert.Equal(t, "account_id = 42", requestBody["filter"]) } func TestMeiliSearchEngine_SearchSendsInboxAccessFilter(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{3, 5}} _, err := engine.Search(context.Background(), 42, "hello", filter) require.NoError(t, err) assert.Equal(t, "account_id = 42 AND (inbox_id = 3 OR inbox_id = 5)", requestBody["filter"]) } func TestMeiliSearchEngine_SearchSendsMessageRecentBaseFilter(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) cutoff := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, MessageCreatedAfter: &cutoff} _, err := engine.Search(context.Background(), 42, "hello", filter) require.NoError(t, err) assert.Equal(t, "account_id = 42 AND created_at_ts >= 1772884800", requestBody["filter"]) } func TestMeiliSearchEngine_SearchUsesReferenceTimeFilterFields(t *testing.T) { seen := map[string]string{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { var requestBody map[string]interface{} require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) seen[r.URL.Path] = requestBody["filter"].(string) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) from := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeConversation, ResultTypeContact, ResultTypeArticle, ResultTypeMessage}, AdvancedSearchEnabled: true, DateFrom: &from} _, err := engine.Search(context.Background(), 42, "hello", filter) require.NoError(t, err) assert.Equal(t, "account_id = 42 AND last_activity_at_ts >= 1772884800", seen["/indexes/gochat_conversations/search"]) assert.Equal(t, "account_id = 42 AND last_activity_at_ts >= 1772884800", seen["/indexes/gochat_contacts/search"]) assert.Equal(t, "account_id = 42 AND updated_at_ts >= 1772884800", seen["/indexes/gochat_articles/search"]) assert.Equal(t, "account_id = 42 AND created_at_ts >= 1772884800", seen["/indexes/gochat_messages/search"]) } func TestMeiliSearchEngine_SearchIgnoresInaccessibleInboxParam(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path) require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) inboxID := uint(9) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, AdvancedSearchEnabled: true, InboxID: &inboxID, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{3, 5}} _, err := engine.Search(context.Background(), 42, "hello", filter) require.NoError(t, err) assert.Equal(t, "account_id = 42 AND (inbox_id = 3 OR inbox_id = 5)", requestBody["filter"]) } func TestMeiliSearchEngine_SearchAppliesExplicitInboxOnlyToMessages(t *testing.T) { seen := map[string]string{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { var requestBody map[string]interface{} require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) seen[r.URL.Path] = requestBody["filter"].(string) return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) inboxID := uint(9) filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeConversation, ResultTypeContact, ResultTypeArticle, ResultTypeMessage}, AdvancedSearchEnabled: true, InboxID: &inboxID} _, err := engine.Search(context.Background(), 42, "hello", filter) require.NoError(t, err) assert.Equal(t, "account_id = 42", seen["/indexes/gochat_conversations/search"]) assert.Equal(t, "account_id = 42", seen["/indexes/gochat_contacts/search"]) assert.Equal(t, "account_id = 42", seen["/indexes/gochat_articles/search"]) assert.Equal(t, "account_id = 42 AND inbox_id = 9", seen["/indexes/gochat_messages/search"]) } func TestMeiliSearchEngine_IndexAndDeleteDocument(t *testing.T) { seen := []string{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { seen = append(seen, r.Method+" "+r.URL.Path) return jsonResponse(http.StatusAccepted, `{"taskUid":1}`), nil }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) doc := ContactDocument(model.Contact{Base: model.Base{ID: 5}, AccountID: 2, Name: "Grace"}) require.NoError(t, engine.IndexDocument(context.Background(), doc)) require.NoError(t, engine.DeleteDocument(context.Background(), ResultTypeContact, 2, 5)) assert.Equal(t, []string{ "POST /indexes/gochat_contacts/documents", "DELETE /indexes/gochat_contacts/documents/2_contact_5", }, seen) } func TestMeiliSearchEngine_BootstrapCreatesMissingIndexesAndSettings(t *testing.T) { created := 0 settings := 0 transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { switch { case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/indexes/"): return jsonResponse(http.StatusNotFound, `{"message":"not found"}`), nil case r.Method == http.MethodPost && r.URL.Path == "/indexes": created++ return jsonResponse(http.StatusAccepted, `{"taskUid":1}`), nil case r.Method == http.MethodPatch && strings.HasSuffix(r.URL.Path, "/settings"): settings++ return jsonResponse(http.StatusAccepted, `{"taskUid":2}`), nil default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) return nil, nil } }) engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) engine.client.SetTransport(transport) require.NoError(t, engine.Bootstrap(context.Background())) assert.Equal(t, len(searchableTypes(nil)), created) assert.Equal(t, len(searchableTypes(nil)), settings) }