package v1 import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" ) // mockSearchRepo implements search.SearchRepoInterface for handler tests. type mockSearchRepo struct { conversations []model.Conversation convTotal int64 convErr error convFilter *search.SearchFilter messages []model.Message msgTotal int64 msgErr error msgFilter *search.SearchFilter contacts []model.Contact contactTotal int64 contactErr error contactFilter *search.SearchFilter companies []model.Company companyTotal int64 companyErr error companyCalled bool articles []model.Article articleTotal int64 articleErr error articleFilter *search.SearchFilter } func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Conversation, int64, error) { m.convFilter = filter return m.conversations, m.convTotal, m.convErr } func (m *mockSearchRepo) SearchMessages(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Message, int64, error) { m.msgFilter = filter return m.messages, m.msgTotal, m.msgErr } func (m *mockSearchRepo) SearchContacts(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Contact, int64, error) { m.contactFilter = filter return m.contacts, m.contactTotal, m.contactErr } func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Company, int64, error) { m.companyCalled = true return m.companies, m.companyTotal, m.companyErr } func (m *mockSearchRepo) SearchArticles(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Article, int64, error) { m.articleFilter = filter return m.articles, m.articleTotal, m.articleErr } // Helper to create model objects without embedded Base in literals. func makeConversation(accountID uint, status string) model.Conversation { return model.Conversation{AccountID: accountID, Status: status} } func makeMessage(accountID, conversationID uint, content string) model.Message { return model.Message{AccountID: accountID, ConversationID: conversationID, Content: content} } func makeContact(accountID uint, name string) model.Contact { return model.Contact{AccountID: accountID, Name: name} } func makeArticle(accountID, portalID uint, title, desc, content, status string) model.Article { return model.Article{AccountID: accountID, PortalID: portalID, Title: title, Description: desc, Content: content, Status: status} } // setupSearchHandlerRouter creates a test router with search handler routes. func setupSearchHandlerRouter(h *SearchHandler) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() api := r.Group("/api/v1/accounts/:account_id/search") { api.GET("", h.GlobalSearch) api.GET("/conversations", h.SearchConversations) api.GET("/messages", h.SearchMessages) api.GET("/contacts", h.SearchContacts) api.GET("/articles", h.SearchArticles) } return r } // ========== GlobalSearch handler tests ========== func TestSearchHandler_GlobalSearch_Success(t *testing.T) { repo := &mockSearchRepo{ conversations: []model.Conversation{makeConversation(1, "open")}, convTotal: 1, messages: []model.Message{makeMessage(1, 1, "hello")}, msgTotal: 1, contacts: []model.Contact{makeContact(1, "Alice")}, contactTotal: 1, articles: []model.Article{makeArticle(1, 1, "FAQ", "desc", "content", "published")}, articleTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search?q=test&page=1&per_page=10", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.NotContains(t, body, "success") payload, ok := body["payload"].(map[string]interface{}) require.True(t, ok) assert.Len(t, payload["conversations"], 1) assert.Len(t, payload["contacts"], 1) assert.Len(t, payload["messages"], 1) assert.Len(t, payload["articles"], 1) } func TestSearchHandler_GlobalSearch_InvalidAccountID(t *testing.T) { repo := &mockSearchRepo{} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/abc/search?q=test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestSearchHandler_GlobalSearch_WithFilterParams(t *testing.T) { repo := &mockSearchRepo{ conversations: []model.Conversation{makeConversation(1, "open")}, convTotal: 1, messages: []model.Message{makeMessage(1, 1, "msg")}, msgTotal: 1, contacts: []model.Contact{makeContact(1, "Bob")}, contactTotal: 1, articles: []model.Article{makeArticle(1, 1, "FAQ", "desc", "content", "published")}, articleTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search?q=test&status=open&page=2&per_page=5&sort_by=updated_at&sort_order=asc", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.NotContains(t, body, "success") payload, ok := body["payload"].(map[string]interface{}) require.True(t, ok) assert.Len(t, payload["conversations"], 1) require.NotNil(t, repo.msgFilter) assert.Equal(t, 15, repo.msgFilter.PerPage) } func TestSearchHandler_GlobalSearch_UsesReferenceResultTypes(t *testing.T) { repo := &mockSearchRepo{ companies: []model.Company{{ID: 99, AccountID: 1, Name: "Acme"}}, companyTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search?q=Acme&types=company", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.False(t, repo.companyCalled) var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) payload, ok := body["payload"].(map[string]interface{}) require.True(t, ok) assert.Contains(t, payload, "conversations") assert.Contains(t, payload, "contacts") assert.Contains(t, payload, "messages") assert.Contains(t, payload, "articles") assert.NotContains(t, payload, "companies") } // ========== SearchConversations handler tests ========== func TestSearchHandler_SearchConversations_Success(t *testing.T) { repo := &mockSearchRepo{ conversations: []model.Conversation{makeConversation(1, "open")}, convTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/conversations?q=billing&page=1&per_page=10", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.NotContains(t, body, "success") payload, ok := body["payload"].(map[string]interface{}) require.True(t, ok) results, ok := payload["conversations"].([]interface{}) require.True(t, ok) require.Len(t, results, 1) } func TestSearchHandler_SearchConversations_ChatwootPayloadShape(t *testing.T) { displayID := uint(42) createdAt := time.Date(2026, 6, 7, 8, 30, 0, 0, time.UTC) conversation := model.Conversation{ Base: model.Base{ID: 7, CreatedAt: createdAt}, DisplayID: &displayID, AccountID: 1, InboxID: 3, ContactID: 5, AssigneeID: uintPtr(9), AdditionalAttributes: datatypes.JSON(`{"mail_subject":"Need pricing"}`), Contact: &model.Contact{Base: model.Base{ID: 5}, AccountID: 1, Name: "Ada", Email: "ada@example.com"}, Inbox: &model.Inbox{Base: model.Base{ID: 3}, AccountID: 1, Name: "Website", ChannelType: "web_widget", ChannelID: 11}, Assignee: &model.User{Base: model.Base{ID: 9}, AccountID: 1, Name: "Agent One", Email: "agent@example.com", Role: "administrator"}, Messages: []model.Message{{ Base: model.Base{ID: 13, CreatedAt: createdAt.Add(-time.Minute)}, AccountID: 1, InboxID: 3, ConversationID: 7, Content: "hello", MessageType: "incoming", ContentType: "text", Status: "sent", }}, } repo := &mockSearchRepo{conversations: []model.Conversation{conversation}, convTotal: 1} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/conversations?q=ada", nil) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var body map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) payload := body["payload"].(map[string]any) results := payload["conversations"].([]any) require.Len(t, results, 1) item := results[0].(map[string]any) assert.Equal(t, float64(42), item["id"]) assert.Equal(t, float64(createdAt.Unix()), item["created_at"]) assert.Equal(t, "Need pricing", item["additional_attributes"].(map[string]any)["mail_subject"]) assert.Equal(t, "Ada", item["contact"].(map[string]any)["name"]) assert.Equal(t, "ada@example.com", item["contact"].(map[string]any)["email"]) assert.Equal(t, "Website", item["inbox"].(map[string]any)["name"]) assert.Equal(t, float64(11), item["inbox"].(map[string]any)["channel_id"]) assert.Equal(t, "Agent One", item["agent"].(map[string]any)["available_name"]) assert.Equal(t, float64(13), item["message"].(map[string]any)["id"]) assert.Equal(t, float64(0), item["message"].(map[string]any)["message_type"]) } func TestSearchHandler_SearchConversations_IgnoresUnsupportedSearchFilters(t *testing.T) { repo := &mockSearchRepo{ conversations: []model.Conversation{makeConversation(1, "open")}, convTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/conversations?q=billing&status=resolved&priority=urgent&labels=vip&inbox_id=5&from=agent:7&message_type=outgoing&contact_source=email&portal_id=2&article_status=draft&locale=fr", nil) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) 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) assert.Empty(t, repo.convFilter.SenderType) assert.Nil(t, repo.convFilter.SenderID) assert.Empty(t, repo.convFilter.MessageType) assert.Empty(t, repo.convFilter.ContactSource) assert.Nil(t, repo.convFilter.PortalID) assert.Empty(t, repo.convFilter.ArticleStatus) assert.Empty(t, repo.convFilter.ArticleLocale) } func TestSearchHandler_SearchConversations_InvalidAccountID(t *testing.T) { repo := &mockSearchRepo{} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/invalid/search/conversations?q=test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestSearchHandler_SearchConversations_ServiceError(t *testing.T) { repo := &mockSearchRepo{convErr: assert.AnError} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/conversations?q=test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) } // ========== SearchMessages handler tests ========== func TestSearchHandler_SearchMessages_Success(t *testing.T) { repo := &mockSearchRepo{ messages: []model.Message{makeMessage(1, 1, "hello world")}, msgTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello&page=1&per_page=99", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.NotContains(t, body, "success") payload, ok := body["payload"].(map[string]interface{}) require.True(t, ok) results, ok := payload["messages"].([]interface{}) require.True(t, ok) require.Len(t, results, 1) require.NotNil(t, repo.msgFilter) assert.Equal(t, 15, repo.msgFilter.PerPage) } func TestSearchHandler_SearchMessages_AddsCurrentUserToFilter(t *testing.T) { repo := &mockSearchRepo{ messages: []model.Message{makeMessage(1, 1, "hello world")}, msgTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello&page=1", nil) req.Header.Set("X-User-ID", "42") router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) require.NotNil(t, repo.msgFilter) if assert.NotNil(t, repo.msgFilter.CurrentUserID) { assert.Equal(t, uint(42), *repo.msgFilter.CurrentUserID) } } func TestSearchHandler_SearchMessages_KeepsOnlyReferenceAdvancedFilters(t *testing.T) { repo := &mockSearchRepo{ messages: []model.Message{makeMessage(1, 1, "hello world")}, msgTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello&from=agent:7&inbox_id=5&message_type=outgoing&content_type=text&private=true&status=resolved&contact_source=email&portal_id=2&article_status=draft", nil) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) require.NotNil(t, repo.msgFilter) require.NotNil(t, repo.msgFilter.SenderID) assert.Equal(t, "agent", repo.msgFilter.SenderType) assert.Equal(t, uint(7), *repo.msgFilter.SenderID) require.NotNil(t, repo.msgFilter.InboxID) assert.Equal(t, uint(5), *repo.msgFilter.InboxID) assert.Empty(t, repo.msgFilter.MessageType) assert.Empty(t, repo.msgFilter.ContentType) assert.Nil(t, repo.msgFilter.Private) assert.Empty(t, repo.msgFilter.Status) assert.Empty(t, repo.msgFilter.ContactSource) assert.Nil(t, repo.msgFilter.PortalID) assert.Empty(t, repo.msgFilter.ArticleStatus) } func TestSearchHandler_SearchMessages_MeiliHitPayloadShape(t *testing.T) { svc := search.NewSearchServiceWithEngine(&stubSearchEngine{ resp: &search.SearchResponse{ Results: []search.SearchResult{{ Type: search.ResultTypeMessage, ID: 9, AccountID: 1, Data: map[string]any{ "data": map[string]any{ "message": map[string]any{ "id": float64(9), "content": "hello from meili", "account_id": float64(1), "conversation_id": float64(42), "message_type": "incoming", "created_at_ts": float64(1700000000), "sender": map[string]any{"id": float64(7), "name": "Ada Contact"}, "attachments": []any{map[string]any{"id": float64(11), "file_type": "file", "data_url": "https://files.example/doc.pdf"}}, }, }, }, }}, ByType: map[string]int64{"message": 1}, }, }, nil) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello", nil) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var body map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) payload := body["payload"].(map[string]any) messages := payload["messages"].([]any) require.Len(t, messages, 1) message := messages[0].(map[string]any) assert.Equal(t, float64(0), message["message_type"]) assert.Equal(t, float64(1700000000), message["created_at"]) assert.Equal(t, float64(42), message["conversation_id"]) assert.Equal(t, "Ada Contact", message["sender"].(map[string]any)["name"]) attachments := message["attachments"].([]any) require.Len(t, attachments, 1) assert.Equal(t, "https://files.example/doc.pdf", attachments[0].(map[string]any)["data_url"]) } func TestSearchHandler_SearchMessages_HydratesChatwootMessagePayload(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:search-message-payload?mode=memory&cache=private"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.Attachment{})) account := model.Account{Name: "Acme"} require.NoError(t, db.Create(&account).Error) inbox := model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "web_widget"} require.NoError(t, db.Create(&inbox).Error) contact := model.Contact{AccountID: account.ID, Name: "Ada Contact", Email: "ada@example.com"} require.NoError(t, db.Create(&contact).Error) agent := model.User{Name: "Agent One", Email: "agent@example.com"} require.NoError(t, db.Create(&agent).Error) displayID := uint(42) conversation := model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open"} require.NoError(t, db.Create(&conversation).Error) message := model.Message{ AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &agent.ID, SenderType: "user", MessageType: "outgoing", ContentType: "text", Status: "sent", Content: "hello with attachment", ContentAttributes: datatypes.JSON([]byte(`{"source":"search"}`)), } require.NoError(t, db.Create(&message).Error) require.NoError(t, db.Create(&model.Attachment{AccountID: account.ID, MessageID: message.ID, FileType: "file", FileName: "quote.pdf", FileURL: "https://files.example/quote.pdf", FileSize: 128}).Error) repo := &mockSearchRepo{messages: []model.Message{{Base: model.Base{ID: message.ID}, AccountID: account.ID}}, msgTotal: 1} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc, db) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello", nil) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var body map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) payload := body["payload"].(map[string]any) messages := payload["messages"].([]any) require.Len(t, messages, 1) got := messages[0].(map[string]any) assert.Equal(t, float64(displayID), got["conversation_id"]) sender := got["sender"].(map[string]any) assert.Equal(t, "Agent One", sender["name"]) attachments := got["attachments"].([]any) require.Len(t, attachments, 1) attachment := attachments[0].(map[string]any) assert.Equal(t, "file", attachment["file_type"]) assert.Equal(t, "https://files.example/quote.pdf", attachment["data_url"]) } func TestSearchHandler_SearchMessages_InvalidAccountID(t *testing.T) { repo := &mockSearchRepo{} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/abc/search/messages?q=test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestSearchHandler_SearchMessages_ServiceError(t *testing.T) { repo := &mockSearchRepo{msgErr: assert.AnError} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) } // ========== SearchContacts handler tests ========== func TestSearchHandler_SearchContacts_Success(t *testing.T) { repo := &mockSearchRepo{ contacts: []model.Contact{makeContact(1, "Alice")}, contactTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/contacts?q=Alice&page=1&per_page=10", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.NotContains(t, body, "success") payload, ok := body["payload"].(map[string]interface{}) require.True(t, ok) results, ok := payload["contacts"].([]interface{}) require.True(t, ok) require.Len(t, results, 1) contact := results[0].(map[string]interface{}) assert.Equal(t, "Alice", contact["name"]) } func TestSearchHandler_SearchContacts_InvalidAccountID(t *testing.T) { repo := &mockSearchRepo{} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/abc/search/contacts?q=test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestSearchHandler_SearchContacts_ServiceError(t *testing.T) { repo := &mockSearchRepo{contactErr: assert.AnError} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/contacts?q=test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) } // ========== SearchArticles handler tests ========== func TestSearchHandler_SearchArticles_Success(t *testing.T) { repo := &mockSearchRepo{ articles: []model.Article{makeArticle(1, 1, "FAQ Guide", "desc", "content", "published")}, articleTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/articles?q=FAQ&page=1&per_page=10", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.NotContains(t, body, "success") payload, ok := body["payload"].(map[string]interface{}) require.True(t, ok) results, ok := payload["articles"].([]interface{}) require.True(t, ok) require.Len(t, results, 1) } func TestSearchHandler_SearchArticles_HydratesPortalAndCategoryPayload(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:search-article-payload?mode=memory&cache=private"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Portal{}, &model.Category{}, &model.Article{})) account := model.Account{Name: "Acme"} require.NoError(t, db.Create(&account).Error) portal := model.Portal{AccountID: account.ID, Name: "Help Center", Slug: "help-center", Locale: "en"} require.NoError(t, db.Create(&portal).Error) category := model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "Billing", Slug: "billing", Locale: "en"} require.NoError(t, db.Create(&category).Error) article := model.Article{AccountID: account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Billing FAQ", Slug: "billing-faq", Content: "billing help", Status: "published", Locale: "en"} require.NoError(t, db.Create(&article).Error) repo := &mockSearchRepo{articles: []model.Article{{Base: model.Base{ID: article.ID}, AccountID: account.ID}}, articleTotal: 1} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc, db) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/articles?q=billing", nil) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var body map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) payload := body["payload"].(map[string]any) articles := payload["articles"].([]any) require.Len(t, articles, 1) got := articles[0].(map[string]any) assert.Equal(t, "help-center", got["portal_slug"]) assert.Equal(t, "Billing", got["category_name"]) } func TestSearchHandler_SearchArticles_InvalidAccountID(t *testing.T) { repo := &mockSearchRepo{} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/abc/search/articles?q=test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestSearchHandler_SearchArticles_ServiceError(t *testing.T) { repo := &mockSearchRepo{articleErr: assert.AnError} svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/articles?q=test", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) } type stubSearchEngine struct { resp *search.SearchResponse err error } func (s *stubSearchEngine) Search(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) (*search.SearchResponse, error) { return s.resp, s.err } func (s *stubSearchEngine) IndexDocument(ctx context.Context, doc search.SearchDocument) error { return nil } func (s *stubSearchEngine) IndexBatch(ctx context.Context, docs []search.SearchDocument) error { return nil } func (s *stubSearchEngine) DeleteDocument(ctx context.Context, docType search.SearchResultType, accountID uint, id uint) error { return nil } func (s *stubSearchEngine) Bootstrap(ctx context.Context) error { return nil } func (s *stubSearchEngine) Close() error { return nil }