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.
938 lines
39 KiB
Go
938 lines
39 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/suite"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/gochat/gochat/internal/channel"
|
|
"github.com/gochat/gochat/internal/llm"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/service"
|
|
)
|
|
|
|
// --- Mock LLM Provider for handler tests ---
|
|
|
|
type mockConvHandlerLLMProvider struct{}
|
|
|
|
func (m *mockConvHandlerLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
return &llm.ChatResponse{}, nil
|
|
}
|
|
|
|
func (m *mockConvHandlerLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
|
|
return &llm.EmbeddingResponse{}, nil
|
|
}
|
|
|
|
func (m *mockConvHandlerLLMProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error {
|
|
return nil
|
|
}
|
|
|
|
// --- Conversation Handler Test Suite ---
|
|
|
|
type ConversationHandlerTestSuite struct {
|
|
suite.Suite
|
|
router *gin.Engine
|
|
db *gorm.DB
|
|
testAccount *model.Account
|
|
testConv *model.Conversation
|
|
testUser *model.User
|
|
testInbox *model.Inbox
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) SetupSuite() {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
s.Require().NoError(err)
|
|
s.db = db
|
|
|
|
err = db.AutoMigrate(
|
|
&model.Account{},
|
|
&model.User{},
|
|
&model.Inbox{},
|
|
&model.Contact{},
|
|
&model.ContactInbox{},
|
|
&model.Conversation{},
|
|
&model.ConversationParticipant{},
|
|
&model.ReportingEvent{},
|
|
&model.Message{},
|
|
&model.Notification{},
|
|
&model.Attachment{},
|
|
&model.InboxMember{},
|
|
&model.AccountUser{},
|
|
&model.CustomRole{},
|
|
&model.Team{},
|
|
&model.TeamMember{},
|
|
&model.Tag{},
|
|
&model.ConversationLabel{},
|
|
)
|
|
s.Require().NoError(err)
|
|
|
|
// Create test account
|
|
account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true, FeatureFlags: `{"conversation_unread_counts":true}`}
|
|
s.Require().NoError(db.Create(account).Error)
|
|
s.testAccount = account
|
|
user := &model.User{Name: "Conv Handler User", Email: "conv-handler@example.com"}
|
|
s.Require().NoError(db.Create(user).Error)
|
|
s.Require().NoError(db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error)
|
|
s.testUser = user
|
|
|
|
// Create inbox and contact
|
|
inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1}
|
|
s.Require().NoError(db.Create(inbox).Error)
|
|
s.testInbox = inbox
|
|
s.Require().NoError(db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error)
|
|
|
|
contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"}
|
|
s.Require().NoError(db.Create(contact).Error)
|
|
|
|
// Create conversation
|
|
conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(db.Create(conv).Error)
|
|
s.testConv = conv
|
|
|
|
// Wire up repos, services, handlers
|
|
convRepo := repository.NewConversationRepo(db)
|
|
msgRepo := repository.NewMessageRepo(db)
|
|
dispatcher := channel.NewDispatcher()
|
|
inboxMemberRepo := repository.NewInboxMemberRepo(db)
|
|
inboxMemberSvc := service.NewInboxMemberService(inboxMemberRepo)
|
|
accountUserRepo := repository.NewAccountUserRepo(db)
|
|
teamRepo := repository.NewTeamRepo(db)
|
|
teamMemberRepo := repository.NewTeamMemberRepo(db)
|
|
conversationSvc := service.NewConversationService(convRepo, msgRepo, dispatcher, inboxMemberSvc, accountUserRepo, teamRepo, teamMemberRepo)
|
|
mockLLM := &mockConvHandlerLLMProvider{}
|
|
messageSvc := service.NewMessageService(msgRepo, dispatcher, mockLLM)
|
|
handler := NewConversationHandler(conversationSvc, messageSvc)
|
|
|
|
// Setup router
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("user_id", user.ID)
|
|
c.Next()
|
|
})
|
|
s.router = r
|
|
|
|
// Register routes
|
|
accountGroup := r.Group("/api/v1/accounts/:account_id")
|
|
{
|
|
conversations := accountGroup.Group("/conversations")
|
|
{
|
|
conversations.GET("/meta", handler.Meta)
|
|
conversations.GET("/unread_counts", handler.UnreadCounts)
|
|
conversations.POST("/:conversation_id/unread", handler.Unread)
|
|
conversations.POST("/:conversation_id/transcript", handler.Transcript)
|
|
conversations.POST("/:conversation_id/custom_attributes", handler.UpdateCustomAttributes)
|
|
conversations.GET("/:conversation_id/attachments", handler.ListAttachments)
|
|
conversations.GET("/:conversation_id/reporting_events", handler.ReportingEvents)
|
|
conversations.POST("/:conversation_id/toggle_typing", handler.ToggleTyping)
|
|
conversations.POST("/:conversation_id/toggle_typing_status", handler.ToggleTyping)
|
|
conversations.POST("/:conversation_id/update_last_seen", handler.UpdateLastSeen)
|
|
conversations.DELETE("/:conversation_id", handler.Delete)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TearDownSuite() {
|
|
if s.db != nil {
|
|
sqlDB, _ := s.db.DB()
|
|
sqlDB.Close()
|
|
}
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TearDownTest() {
|
|
s.db.Exec("DELETE FROM attachments")
|
|
s.db.Exec("DELETE FROM reporting_events")
|
|
s.db.Exec("DELETE FROM conversations")
|
|
s.db.Exec("DELETE FROM contact_inboxes")
|
|
s.db.Exec("DELETE FROM contacts")
|
|
s.db.Exec("DELETE FROM inbox_members")
|
|
s.db.Exec("DELETE FROM account_users")
|
|
s.db.Exec("DELETE FROM inboxes")
|
|
s.db.Exec("DELETE FROM messages")
|
|
s.db.Exec("DELETE FROM accounts")
|
|
|
|
// Re-seed base data
|
|
account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true, FeatureFlags: `{"conversation_unread_counts":true}`}
|
|
s.Require().NoError(s.db.Create(account).Error)
|
|
s.testAccount = account
|
|
s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: account.ID, UserID: s.testUser.ID, Role: "administrator"}).Error)
|
|
|
|
inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: s.testUser.ID}).Error)
|
|
|
|
contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"}
|
|
s.Require().NoError(s.db.Create(contact).Error)
|
|
|
|
conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(conv).Error)
|
|
s.testConv = conv
|
|
}
|
|
|
|
// Helper to build account URL prefix
|
|
func (s *ConversationHandlerTestSuite) accountURL() string {
|
|
return "/api/v1/accounts/" + strconv.FormatUint(uint64(s.testAccount.ID), 10)
|
|
}
|
|
|
|
// ========== Meta Handler Tests ==========
|
|
|
|
func (s *ConversationHandlerTestSuite) TestMeta_Success() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
var resp struct {
|
|
Meta struct {
|
|
MineCount int64 `json:"mine_count"`
|
|
AssignedCount int64 `json:"assigned_count"`
|
|
UnassignedCount int64 `json:"unassigned_count"`
|
|
AllCount int64 `json:"all_count"`
|
|
} `json:"meta"`
|
|
}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
assert.NoError(s.T(), err)
|
|
assert.NotContains(s.T(), w.Body.String(), "success")
|
|
assert.Equal(s.T(), int64(1), resp.Meta.AllCount)
|
|
assert.Equal(s.T(), int64(1), resp.Meta.UnassignedCount)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestMeta_FiltersStatusAndIgnoresAssigneeTypeForCounts() {
|
|
assigned := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, AssigneeID: &s.testUser.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(assigned).Error)
|
|
resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, AssigneeID: &s.testUser.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(resolved).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta?assignee_type=assigned", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
var resp struct {
|
|
Meta struct {
|
|
MineCount int64 `json:"mine_count"`
|
|
AssignedCount int64 `json:"assigned_count"`
|
|
UnassignedCount int64 `json:"unassigned_count"`
|
|
AllCount int64 `json:"all_count"`
|
|
} `json:"meta"`
|
|
}
|
|
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), int64(1), resp.Meta.MineCount)
|
|
assert.Equal(s.T(), int64(1), resp.Meta.AssignedCount)
|
|
assert.Equal(s.T(), int64(1), resp.Meta.UnassignedCount)
|
|
assert.Equal(s.T(), int64(2), resp.Meta.AllCount)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestMeta_QueryAndSourceIDFilters() {
|
|
contactInbox := &model.ContactInbox{ContactID: s.testConv.ContactID, InboxID: s.testConv.InboxID, SourceID: "widget-source"}
|
|
s.Require().NoError(s.db.Create(contactInbox).Error)
|
|
s.Require().NoError(s.db.Model(s.testConv).Updates(map[string]any{"contact_inbox_id": contactInbox.ID, "assignee_id": s.testUser.ID}).Error)
|
|
other := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(other).Error)
|
|
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: s.testConv.ID, Content: "needle from widget", MessageType: string(model.MessageTypeIncoming)}).Error)
|
|
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: other.ID, Content: "needle from other", MessageType: string(model.MessageTypeIncoming)}).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta?q=needle&source_id=widget-source", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
var resp struct {
|
|
Meta struct {
|
|
MineCount int64 `json:"mine_count"`
|
|
AllCount int64 `json:"all_count"`
|
|
} `json:"meta"`
|
|
}
|
|
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), int64(1), resp.Meta.AllCount)
|
|
assert.Equal(s.T(), int64(1), resp.Meta.MineCount)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestMeta_QuerySkipsStatusFilter() {
|
|
resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(resolved).Error)
|
|
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: s.testConv.ID, Content: "needle open", MessageType: string(model.MessageTypeIncoming)}).Error)
|
|
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: resolved.ID, Content: "needle resolved", MessageType: string(model.MessageTypeOutgoing)}).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta?q=needle&status=open", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
var resp struct {
|
|
Meta struct {
|
|
AllCount int64 `json:"all_count"`
|
|
} `json:"meta"`
|
|
}
|
|
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), int64(2), resp.Meta.AllCount)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestMeta_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/v1/accounts/invalid/conversations/meta", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
// ========== Unread Handler Tests ==========
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUnread_Success() {
|
|
// Set agent_last_seen_at on the conversation
|
|
seenAt := int64(1700000000)
|
|
s.Require().NoError(s.db.Model(s.testConv).Update("agent_last_seen_at", seenAt).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/unread", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
var resp struct {
|
|
ID uint `json:"id"`
|
|
Status string `json:"status"`
|
|
}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
assert.NoError(s.T(), err)
|
|
assert.Equal(s.T(), s.testConv.ID, resp.ID)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUnread_DisplayIDRouteSetsLastSeenBeforeIncoming() {
|
|
displayID := uint(909)
|
|
s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error)
|
|
incomingCreatedAt := time.Now().Add(-10 * time.Minute)
|
|
oldSeen := incomingCreatedAt.Add(5 * time.Minute).Unix()
|
|
s.Require().NoError(s.db.Model(s.testConv).Updates(map[string]any{
|
|
"agent_last_seen_at": oldSeen,
|
|
"assignee_last_seen_at": oldSeen,
|
|
}).Error)
|
|
message := &model.Message{
|
|
AccountID: s.testAccount.ID,
|
|
InboxID: s.testInbox.ID,
|
|
ConversationID: s.testConv.ID,
|
|
MessageType: string(model.MessageTypeIncoming),
|
|
Content: "unread transition",
|
|
ContentType: "text",
|
|
SenderType: "contact",
|
|
}
|
|
s.Require().NoError(s.db.Create(message).Error)
|
|
s.Require().NoError(s.db.Model(message).Updates(map[string]any{"created_at": incomingCreatedAt, "updated_at": incomingCreatedAt}).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(displayID), 10)+"/unread", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]any
|
|
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), float64(displayID), resp["id"])
|
|
assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), int64(resp["agent_last_seen_at"].(float64)))
|
|
assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), int64(resp["assignee_last_seen_at"].(float64)))
|
|
assert.NotContains(s.T(), resp, "success")
|
|
|
|
var stored model.Conversation
|
|
s.Require().NoError(s.db.First(&stored, s.testConv.ID).Error)
|
|
s.Require().NotNil(stored.AgentLastSeenAt)
|
|
s.Require().NotNil(stored.AssigneeLastSeenAt)
|
|
assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), *stored.AgentLastSeenAt)
|
|
assert.Equal(s.T(), incomingCreatedAt.Add(-time.Second).Unix(), *stored.AssigneeLastSeenAt)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUnread_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/unread", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUnread_InvalidConversationID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/invalid/unread", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUnread_ConversationNotFound() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/9999/unread", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// Service returns "not found" error, handleServiceError maps to 404
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// ========== Transcript Handler Tests ==========
|
|
|
|
func (s *ConversationHandlerTestSuite) TestTranscript_Success() {
|
|
body := map[string]string{"email": "test@example.com"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestTranscript_InvalidAccountID() {
|
|
body := map[string]string{"email": "test@example.com"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/transcript", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestTranscript_MissingEmail() {
|
|
body := map[string]string{} // no email
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
|
|
var resp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
assert.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "email param missing", resp["error"])
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestTranscript_InvalidEmail() {
|
|
body := map[string]string{"email": "not-an-email"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestTranscript_PaymentRequiredWhenDisabled() {
|
|
s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{
|
|
"limits": datatypes.JSON(`{"email_transcript_enabled":false}`),
|
|
"custom_attributes": datatypes.JSON(`{}`),
|
|
}).Error)
|
|
s.T().Cleanup(func() {
|
|
_ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error
|
|
})
|
|
bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"})
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusPaymentRequired, w.Code)
|
|
var resp map[string]any
|
|
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), "Email transcript is not available on your plan", resp["error"])
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestTranscript_TooManyRequestsWhenRateLimited() {
|
|
s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{
|
|
"limits": datatypes.JSON(`{"emails":1}`),
|
|
"custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`),
|
|
}).Error)
|
|
s.T().Cleanup(func() {
|
|
_ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error
|
|
})
|
|
bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"})
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusTooManyRequests, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestTranscript_ConversationNotFound() {
|
|
body := map[string]string{"email": "test@example.com"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/9999/transcript", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestDelete_SuccessReturnsChatwootHeadOK() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodDelete, s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
|
|
var deleted model.Conversation
|
|
assert.Error(s.T(), s.db.First(&deleted, s.testConv.ID).Error)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestDelete_ConversationNotFound() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodDelete, s.accountURL()+"/conversations/9999", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// ========== UpdateCustomAttributes Handler Tests ==========
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_Success() {
|
|
body := map[string]interface{}{
|
|
"custom_attributes": map[string]string{"priority_reason": "vip_customer", "region": "us-west"},
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/custom_attributes", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
var resp struct {
|
|
CustomAttributes map[string]interface{} `json:"custom_attributes"`
|
|
}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
assert.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "vip_customer", resp.CustomAttributes["priority_reason"])
|
|
var rawResp map[string]interface{}
|
|
err = json.Unmarshal(w.Body.Bytes(), &rawResp)
|
|
assert.NoError(s.T(), err)
|
|
assert.NotContains(s.T(), rawResp, "id")
|
|
assert.NotContains(s.T(), rawResp, "success")
|
|
assert.NotContains(s.T(), rawResp, "payload")
|
|
assert.NotContains(s.T(), rawResp, "meta")
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_EmptyObjectReturnsChatwootShape() {
|
|
body := map[string]interface{}{
|
|
"custom_attributes": map[string]interface{}{},
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/custom_attributes", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
var rawResp map[string]interface{}
|
|
err := json.Unmarshal(w.Body.Bytes(), &rawResp)
|
|
assert.NoError(s.T(), err)
|
|
assert.Equal(s.T(), map[string]interface{}{}, rawResp["custom_attributes"])
|
|
assert.Len(s.T(), rawResp, 1)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_InvalidAccountID() {
|
|
body := map[string]interface{}{
|
|
"custom_attributes": map[string]string{"key": "value"},
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/custom_attributes", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_MissingBody() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/custom_attributes", nil)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// ShouldBindJSON fails without body
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_ConversationNotFound() {
|
|
body := map[string]interface{}{
|
|
"custom_attributes": map[string]string{"key": "value"},
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/9999/custom_attributes", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// ========== UnreadCounts Handler Tests ==========
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUnreadCounts_Success() {
|
|
s.Require().NoError(s.db.Model(s.testAccount).Update("feature_flags", `{"conversation_unread_counts":true}`).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/unread_counts", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
var resp struct {
|
|
Payload struct {
|
|
Inboxes map[string]int64 `json:"inboxes"`
|
|
Labels map[string]int64 `json:"labels"`
|
|
Teams map[string]int64 `json:"teams"`
|
|
} `json:"payload"`
|
|
}
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
assert.NoError(s.T(), err)
|
|
assert.NotContains(s.T(), w.Body.String(), "success")
|
|
assert.NotNil(s.T(), resp.Payload.Inboxes)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUnreadCounts_FeatureDisabled() {
|
|
account := &model.Account{Name: "Unread Disabled", Locale: "en", Active: true}
|
|
s.Require().NoError(s.db.Create(account).Error)
|
|
s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: account.ID, UserID: s.testUser.ID, Role: "administrator"}).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+strconv.FormatUint(uint64(account.ID), 10)+"/conversations/unread_counts", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusForbidden, w.Code)
|
|
var resp map[string]any
|
|
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), "Conversation unread counts feature not enabled for this account", resp["error"])
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUnreadCounts_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/v1/accounts/invalid/conversations/unread_counts", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestListAttachments_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/conversations/1/attachments", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestListAttachments_InvalidConversationID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/attachments", s.testAccount.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestListAttachmentsReturnsChatwootPayload() {
|
|
message := &model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: s.testConv.ID, Content: "file", MessageType: "incoming", ContentType: "text", Status: "sent"}
|
|
s.Require().NoError(s.db.Create(message).Error)
|
|
attachment := &model.Attachment{AccountID: s.testAccount.ID, MessageID: message.ID, FileType: "file", FileURL: "https://cdn.example.com/report.pdf", FileName: "report.pdf", FileSize: 2048}
|
|
s.Require().NoError(s.db.Create(attachment).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/attachments", s.accountURL(), s.testConv.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]any
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), float64(1), resp["meta"].(map[string]any)["total_count"])
|
|
payload := resp["payload"].([]any)
|
|
assert.Len(s.T(), payload, 1)
|
|
item := payload[0].(map[string]any)
|
|
assert.Equal(s.T(), float64(attachment.ID), item["id"])
|
|
assert.Equal(s.T(), float64(message.ID), item["message_id"])
|
|
assert.Equal(s.T(), "file", item["file_type"])
|
|
assert.Equal(s.T(), "https://cdn.example.com/report.pdf", item["data_url"])
|
|
assert.Equal(s.T(), "pdf", item["extension"])
|
|
assert.Contains(s.T(), item, "created_at")
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestListAttachmentsUsesChatwootFixedPageSize() {
|
|
conversation := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(conversation).Error)
|
|
message := &model.Message{AccountID: s.testAccount.ID, InboxID: conversation.InboxID, ConversationID: conversation.ID, Content: "files", MessageType: "incoming", ContentType: "text", Status: "sent"}
|
|
s.Require().NoError(s.db.Create(message).Error)
|
|
|
|
baseTime := time.Now().Add(-time.Hour)
|
|
var newestID uint
|
|
for i := 0; i < 30; i++ {
|
|
attachment := &model.Attachment{
|
|
Base: model.Base{CreatedAt: baseTime.Add(time.Duration(i) * time.Minute), UpdatedAt: baseTime.Add(time.Duration(i) * time.Minute)},
|
|
AccountID: s.testAccount.ID,
|
|
MessageID: message.ID,
|
|
FileType: "file",
|
|
FileURL: fmt.Sprintf("https://cdn.example.com/file-%02d.txt", i),
|
|
FileName: fmt.Sprintf("file-%02d.txt", i),
|
|
}
|
|
s.Require().NoError(s.db.Create(attachment).Error)
|
|
newestID = attachment.ID
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/attachments?per_page=5", s.accountURL(), conversation.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]any
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), float64(30), resp["meta"].(map[string]any)["total_count"])
|
|
payload := resp["payload"].([]any)
|
|
assert.Len(s.T(), payload, 30)
|
|
first := payload[0].(map[string]any)
|
|
assert.Equal(s.T(), float64(newestID), first["id"])
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestReportingEventsReturnsRawArrayInCreatedOrder() {
|
|
conversationID := s.testConv.ID
|
|
baseTime := time.Now().Add(-2 * time.Hour).UTC()
|
|
newer := &model.ReportingEvent{
|
|
Base: model.Base{CreatedAt: baseTime.Add(time.Hour), UpdatedAt: baseTime.Add(time.Hour)},
|
|
AccountID: s.testAccount.ID,
|
|
Name: "resolution_time",
|
|
Value: 42,
|
|
ValueInBusinessHours: 21,
|
|
ConversationID: &conversationID,
|
|
EventStartTime: baseTime,
|
|
EventEndTime: baseTime.Add(time.Minute),
|
|
}
|
|
older := &model.ReportingEvent{
|
|
Base: model.Base{CreatedAt: baseTime, UpdatedAt: baseTime},
|
|
AccountID: s.testAccount.ID,
|
|
Name: "first_response",
|
|
Value: 10,
|
|
ValueInBusinessHours: 5,
|
|
ConversationID: &conversationID,
|
|
EventStartTime: baseTime.Add(-time.Minute),
|
|
EventEndTime: baseTime,
|
|
}
|
|
s.Require().NoError(s.db.Create(newer).Error)
|
|
s.Require().NoError(s.db.Create(older).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/reporting_events", s.accountURL(), s.testConv.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
|
|
var resp []map[string]any
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Len(s.T(), resp, 2)
|
|
assert.Equal(s.T(), float64(older.ID), resp[0]["id"])
|
|
assert.Equal(s.T(), "first_response", resp[0]["name"])
|
|
assert.Equal(s.T(), float64(10), resp[0]["value"])
|
|
assert.Equal(s.T(), float64(5), resp[0]["value_in_business_hours"])
|
|
assert.Equal(s.T(), float64(s.testAccount.ID), resp[0]["account_id"])
|
|
assert.Equal(s.T(), float64(s.testConv.ID), resp[0]["conversation_id"])
|
|
assert.Contains(s.T(), resp[0], "inbox_id")
|
|
assert.Nil(s.T(), resp[0]["inbox_id"])
|
|
assert.Contains(s.T(), resp[0], "user_id")
|
|
assert.Nil(s.T(), resp[0]["user_id"])
|
|
assert.Contains(s.T(), resp[0], "event_start_time")
|
|
assert.Contains(s.T(), resp[0], "event_end_time")
|
|
assert.NotContains(s.T(), resp[0], "success")
|
|
assert.NotContains(s.T(), resp[0], "payload")
|
|
assert.NotContains(s.T(), resp[0], "meta")
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestReportingEventsUsesDisplayIDAndScopesAccount() {
|
|
displayID := uint(880)
|
|
s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error)
|
|
conversationID := s.testConv.ID
|
|
event := &model.ReportingEvent{
|
|
AccountID: s.testAccount.ID,
|
|
Name: "reply_time",
|
|
Value: 7,
|
|
ValueInBusinessHours: 3,
|
|
ConversationID: &conversationID,
|
|
EventStartTime: time.Now().Add(-time.Minute).UTC(),
|
|
EventEndTime: time.Now().UTC(),
|
|
}
|
|
s.Require().NoError(s.db.Create(event).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/reporting_events", s.accountURL(), displayID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
|
|
var resp []map[string]any
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Len(s.T(), resp, 1)
|
|
assert.Equal(s.T(), float64(event.ID), resp[0]["id"])
|
|
|
|
otherAccount := &model.Account{Name: "OtherOrg", Locale: "en", Active: true}
|
|
s.Require().NoError(s.db.Create(otherAccount).Error)
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/reporting_events", otherAccount.ID, displayID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestReportingEventsInvalidParams() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/conversations/1/reporting_events", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/reporting_events", s.testAccount.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/toggle_typing", bytes.NewBufferString(`{"typing_status":"on"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidConversationID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/toggle_typing", s.testAccount.ID), bytes.NewBufferString(`{"typing_status":"on"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestToggleTyping_MissingFieldsIsNoopSuccess() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestToggleTypingStatus_ChatwootRouteSuccess() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing_status", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{"typing_status":"typing_on","is_private":false}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestToggleTyping_EmptyBodyIsNoopSuccess() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidJSON() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_SuccessMarksNotificationRead() {
|
|
oldSeen := time.Now().Add(-2 * time.Hour).Unix()
|
|
s.Require().NoError(s.db.Model(s.testConv).Update("agent_last_seen_at", oldSeen).Error)
|
|
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new message"}).Error)
|
|
accountID := s.testAccount.ID
|
|
notification := &model.Notification{UserID: s.testUser.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: s.testConv.ID}
|
|
s.Require().NoError(s.db.Create(notification).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/update_last_seen", s.testAccount.ID, s.testConv.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
|
|
var conversation model.Conversation
|
|
s.Require().NoError(s.db.First(&conversation, s.testConv.ID).Error)
|
|
s.Require().NotNil(conversation.AgentLastSeenAt)
|
|
assert.Greater(s.T(), *conversation.AgentLastSeenAt, oldSeen)
|
|
|
|
var updatedNotification model.Notification
|
|
s.Require().NoError(s.db.First(&updatedNotification, notification.ID).Error)
|
|
assert.NotNil(s.T(), updatedNotification.ReadAt)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_DisplayIDRouteMarksNotificationRead() {
|
|
displayID := uint(910)
|
|
s.Require().NoError(s.db.Model(s.testConv).Update("display_id", displayID).Error)
|
|
oldSeen := time.Now().Add(-2 * time.Hour).Unix()
|
|
s.Require().NoError(s.db.Model(s.testConv).Update("agent_last_seen_at", oldSeen).Error)
|
|
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new display-id message"}).Error)
|
|
accountID := s.testAccount.ID
|
|
notification := &model.Notification{UserID: s.testUser.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: s.testConv.ID}
|
|
s.Require().NoError(s.db.Create(notification).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/update_last_seen", s.testAccount.ID, displayID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
|
|
var conversation model.Conversation
|
|
s.Require().NoError(s.db.First(&conversation, s.testConv.ID).Error)
|
|
s.Require().NotNil(conversation.AgentLastSeenAt)
|
|
assert.Greater(s.T(), *conversation.AgentLastSeenAt, oldSeen)
|
|
|
|
var updatedNotification model.Notification
|
|
s.Require().NoError(s.db.First(&updatedNotification, notification.ID).Error)
|
|
assert.NotNil(s.T(), updatedNotification.ReadAt)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/update_last_seen", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_InvalidConversationID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/update_last_seen", s.testAccount.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
// Run the test suite
|
|
func TestConversationHandlerTestSuite(t *testing.T) {
|
|
suite.Run(t, new(ConversationHandlerTestSuite))
|
|
}
|