Align GoChat with Chatwoot frontend contracts

This commit is contained in:
2026-06-13 22:13:32 +08:00
parent 71abf58636
commit d884fdda0a
162 changed files with 9825 additions and 528 deletions
@@ -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",