package search import ( "context" "encoding/json" "io" "net/http" "strings" "testing" "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) { conv := makeConversation(12, 3, "open", "billing,urgent") conv.InboxID = 7 conv.ContactID = 9 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) } 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_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}, 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\" AND sender_id = 77", requestBody["filter"]) } 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) }