Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
727 lines
27 KiB
Go
727 lines
27 KiB
Go
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(1), item["account_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"])
|
|
message := item["message"].(map[string]any)
|
|
assert.Equal(t, float64(13), message["id"])
|
|
assert.Equal(t, float64(1), message["account_id"])
|
|
assert.Equal(t, float64(0), message["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(1), message["account_id"])
|
|
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(account.ID), got["account_id"])
|
|
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 }
|