Align GoChat with Chatwoot frontend contracts
This commit is contained in:
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/campaign"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
@@ -33,9 +35,10 @@ func marshalNested(key string, body map[string]interface{}) []byte {
|
||||
type CampaignHandlerTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
db *gorm.DB
|
||||
router *gin.Engine
|
||||
handler *CampaignHandler
|
||||
db *gorm.DB
|
||||
router *gin.Engine
|
||||
handler *CampaignHandler
|
||||
listener *campaignRecordingListener
|
||||
|
||||
account *model.Account
|
||||
inbox *model.Inbox
|
||||
@@ -44,6 +47,31 @@ type CampaignHandlerTestSuite struct {
|
||||
displayIDCounter uint
|
||||
}
|
||||
|
||||
type campaignRecordingListener struct {
|
||||
events []*channel.ChannelEvent
|
||||
}
|
||||
|
||||
func (l *campaignRecordingListener) Name() string { return "campaign_recording_listener" }
|
||||
|
||||
func (l *campaignRecordingListener) OnEvent(_ context.Context, event *channel.ChannelEvent) error {
|
||||
l.events = append(l.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *campaignRecordingListener) reset() {
|
||||
l.events = nil
|
||||
}
|
||||
|
||||
func (l *campaignRecordingListener) eventsByType(eventType channel.EventType) []*channel.ChannelEvent {
|
||||
var events []*channel.ChannelEvent
|
||||
for _, event := range l.events {
|
||||
if event.Type == eventType {
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func (s *CampaignHandlerTestSuite) nextDisplayID() uint {
|
||||
s.displayIDCounter++
|
||||
return s.displayIDCounter
|
||||
@@ -60,7 +88,10 @@ func (s *CampaignHandlerTestSuite) SetupSuite() {
|
||||
|
||||
s.Require().NoError(db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.Contact{},
|
||||
&model.Conversation{},
|
||||
&model.Inbox{},
|
||||
&model.Message{},
|
||||
&campaign.Campaign{},
|
||||
), "failed to auto-migrate models")
|
||||
|
||||
@@ -68,7 +99,10 @@ func (s *CampaignHandlerTestSuite) SetupSuite() {
|
||||
|
||||
// Wire repos → services → handler
|
||||
campaignRepo := repository.NewCampaignRepo(db)
|
||||
campaignSvc := campaign.NewCampaignService(db)
|
||||
dispatcher := channel.NewDispatcher()
|
||||
s.listener = &campaignRecordingListener{}
|
||||
dispatcher.Register(s.listener)
|
||||
campaignSvc := campaign.NewCampaignService(db, dispatcher)
|
||||
svc := service.NewCampaignService(campaignSvc, campaignRepo)
|
||||
s.handler = NewCampaignHandler(svc)
|
||||
|
||||
@@ -111,7 +145,11 @@ func (s *CampaignHandlerTestSuite) TearDownSuite() {
|
||||
|
||||
// SetupTest resets data between tests.
|
||||
func (s *CampaignHandlerTestSuite) SetupTest() {
|
||||
s.listener.reset()
|
||||
s.db.Exec("DELETE FROM campaigns")
|
||||
s.db.Exec("DELETE FROM messages")
|
||||
s.db.Exec("DELETE FROM conversations")
|
||||
s.db.Exec("DELETE FROM contacts")
|
||||
s.db.Exec("DELETE FROM inboxes")
|
||||
s.db.Exec("DELETE FROM accounts")
|
||||
|
||||
@@ -402,6 +440,158 @@ func (s *CampaignHandlerTestSuite) TestUpdate_Success() {
|
||||
s.Equal(int64(1780828200), updated.ScheduledAt.Unix())
|
||||
}
|
||||
|
||||
func (s *CampaignHandlerTestSuite) TestChatwootFrontendPayloadsAndLifecycleUseDisplayID() {
|
||||
s.displayIDCounter = 39
|
||||
s.seedCampaign("Existing Campaign", "Existing message", "ongoing")
|
||||
smsInbox := s.seedInbox("Channel::Sms")
|
||||
body := map[string]interface{}{
|
||||
"inbox_id": smsInbox.ID,
|
||||
"title": "Frontend Campaign",
|
||||
"message": "Hello from Woochat",
|
||||
"description": "Dashboard-created one-off campaign",
|
||||
"enabled": true,
|
||||
"scheduled_at": "2026-06-07T10:30:00Z",
|
||||
"audience": []map[string]interface{}{{"type": "Label", "id": 7}},
|
||||
"trigger_rules": map[string]interface{}{"url": "https://example.com/pricing"},
|
||||
"template_params": map[string]interface{}{"first_name": "Jane"},
|
||||
"trigger_only_during_business_hours": true,
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", s.accountURL(), bytes.NewReader(marshalNested("campaign", body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var createResp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &createResp))
|
||||
s.assertChatwootCampaignPayload(createResp, "Frontend Campaign", "one_off", smsInbox.ID)
|
||||
s.Equal(float64(41), createResp["id"])
|
||||
s.Equal(float64(1780828200), createResp["scheduled_at"])
|
||||
s.Equal([]interface{}{map[string]interface{}{"id": float64(7), "type": "Label"}}, createResp["audience"])
|
||||
s.Equal(map[string]interface{}{"first_name": "Jane"}, createResp["template_params"])
|
||||
s.Equal(map[string]interface{}{"url": "https://example.com/pricing"}, createResp["trigger_rules"])
|
||||
s.Equal(true, createResp["trigger_only_during_business_hours"])
|
||||
|
||||
createdDisplayID := uint(createResp["id"].(float64))
|
||||
var created campaign.Campaign
|
||||
s.Require().NoError(s.db.Where("display_id = ? AND account_id = ?", createdDisplayID, s.account.ID).First(&created).Error)
|
||||
s.NotEqual(created.ID, createdDisplayID)
|
||||
|
||||
updateBody := map[string]interface{}{
|
||||
"inbox_id": s.inbox.ID,
|
||||
"title": "Frontend Campaign Updated",
|
||||
"message": "Updated ongoing message",
|
||||
"scheduled_at": nil,
|
||||
"trigger_rules": map[string]interface{}{"time_on_page": 10},
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PATCH", s.accountURL()+"/"+strconv.FormatUint(uint64(createdDisplayID), 10), bytes.NewReader(marshalNested("campaign", updateBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var updateResp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &updateResp))
|
||||
s.assertChatwootCampaignPayload(updateResp, "Frontend Campaign Updated", "ongoing", s.inbox.ID)
|
||||
s.Equal(float64(createdDisplayID), updateResp["id"])
|
||||
s.NotContains(updateResp, "scheduled_at")
|
||||
s.NotContains(updateResp, "audience")
|
||||
s.Equal(map[string]interface{}{"time_on_page": float64(10)}, updateResp["trigger_rules"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(createdDisplayID), 10)+"/start", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var startResp struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &startResp))
|
||||
s.True(startResp.Success)
|
||||
s.Equal("campaign triggered successfully", startResp.Data["message"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(createdDisplayID), 10)+"/stop", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var stopResp struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &stopResp))
|
||||
s.True(stopResp.Success)
|
||||
s.Equal("campaign stopped successfully", stopResp.Data["message"])
|
||||
|
||||
var updated campaign.Campaign
|
||||
s.Require().NoError(s.db.First(&updated, created.ID).Error)
|
||||
s.Equal(campaign.CampaignStatusCompleted, updated.CampaignStatus)
|
||||
}
|
||||
|
||||
func (s *CampaignHandlerTestSuite) TestStartCreatesCampaignConversationsAndMessages() {
|
||||
contact := &model.Contact{AccountID: s.account.ID, Name: "Campaign Contact", Email: "campaign@example.com"}
|
||||
s.Require().NoError(s.db.Create(contact).Error)
|
||||
c := s.seedCampaign("Trigger Campaign", "Triggered campaign message", "one_off")
|
||||
s.Require().NoError(s.db.Model(&campaign.Campaign{}).Where("id = ?", c.ID).Update("audience", `{"contact_ids":[`+strconv.FormatUint(uint64(contact.ID), 10)+`]}`).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(c.DisplayID), 10)+"/start", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
|
||||
var conv model.Conversation
|
||||
s.Require().NoError(s.db.Where("account_id = ? AND inbox_id = ? AND contact_id = ? AND campaign_id = ?", s.account.ID, s.inbox.ID, contact.ID, c.ID).First(&conv).Error)
|
||||
s.Equal("open", conv.Status)
|
||||
s.Equal("campaign", conv.ChannelType)
|
||||
s.Require().NotNil(conv.CampaignID)
|
||||
s.Equal(c.ID, *conv.CampaignID)
|
||||
|
||||
var msg model.Message
|
||||
s.Require().NoError(s.db.Where("conversation_id = ? AND account_id = ?", conv.ID, s.account.ID).First(&msg).Error)
|
||||
s.Equal("Triggered campaign message", msg.Content)
|
||||
s.Equal("template", msg.ContentType)
|
||||
s.Equal("outgoing", msg.MessageType)
|
||||
s.Equal("agent", msg.SenderType)
|
||||
|
||||
conversationEvents := s.listener.eventsByType(channel.EventConversationCreated)
|
||||
s.Require().Len(conversationEvents, 1)
|
||||
s.Equal(s.account.ID, conversationEvents[0].AccountID)
|
||||
s.Equal(s.inbox.ID, conversationEvents[0].InboxID)
|
||||
s.Equal(conv.ID, conversationEvents[0].ConversationID)
|
||||
s.Equal(contact.ID, conversationEvents[0].ContactID)
|
||||
s.Equal(channel.ChannelType(s.inbox.ChannelType), conversationEvents[0].Channel)
|
||||
s.Equal(c.ID, conversationEvents[0].Data["campaign_id"])
|
||||
s.Equal(conv.ID, conversationEvents[0].Data["conversation"].(*model.Conversation).ID)
|
||||
|
||||
messageEvents := s.listener.eventsByType(channel.EventMessageCreated)
|
||||
s.Require().Len(messageEvents, 1)
|
||||
s.Equal(conv.ID, messageEvents[0].ConversationID)
|
||||
s.Equal(c.ID, messageEvents[0].Data["campaign_id"])
|
||||
s.Equal(msg.ID, messageEvents[0].Data["message"].(*model.Message).ID)
|
||||
s.Equal(s.inbox.ID, messageEvents[0].Data["inbox"].(*model.Inbox).ID)
|
||||
s.Len(s.listener.eventsByType(channel.EventConversationOpened), 1)
|
||||
s.Len(s.listener.eventsByType(channel.EventMessageOutgoing), 1)
|
||||
}
|
||||
|
||||
func (s *CampaignHandlerTestSuite) assertChatwootCampaignPayload(resp map[string]interface{}, title, campaignType string, inboxID uint) {
|
||||
s.NotContains(resp, "success")
|
||||
s.NotContains(resp, "data")
|
||||
s.Equal(title, resp["title"])
|
||||
s.Equal(campaignType, resp["campaign_type"])
|
||||
s.Equal(float64(s.account.ID), resp["account_id"])
|
||||
s.Equal(string(campaign.CampaignStatusActive), resp["campaign_status"])
|
||||
s.Contains(resp, "message")
|
||||
s.Contains(resp, "description")
|
||||
s.Contains(resp, "enabled")
|
||||
s.Contains(resp, "trigger_rules")
|
||||
s.Contains(resp, "template_params")
|
||||
s.Contains(resp, "created_at")
|
||||
s.Contains(resp, "updated_at")
|
||||
inbox, ok := resp["inbox"].(map[string]interface{})
|
||||
s.Require().True(ok)
|
||||
s.Equal(float64(inboxID), inbox["id"])
|
||||
}
|
||||
|
||||
func (s *CampaignHandlerTestSuite) TestUpdate_NotFound() {
|
||||
body := map[string]interface{}{
|
||||
"title": "Updated Title",
|
||||
|
||||
Reference in New Issue
Block a user