Files
gochat/backend/internal/handler/api/v1/campaign_handler_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
2026-07-07 14:44:12 +08:00

925 lines
31 KiB
Go

package v1
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"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"
)
// marshalNested wraps body under the given key to match Chatwoot params.require(:model) format.
// e.g. marshalNested("campaign", body) → {"campaign": {...}}
func marshalNested(key string, body map[string]interface{}) []byte {
wrapped := map[string]interface{}{key: body}
b, _ := json.Marshal(wrapped)
return b
}
// CampaignHandlerTestSuite tests CampaignHandler CRUD + lifecycle methods
// with a real SQLite database and wired services.
type CampaignHandlerTestSuite struct {
suite.Suite
db *gorm.DB
router *gin.Engine
handler *CampaignHandler
listener *campaignRecordingListener
account *model.Account
inbox *model.Inbox
// Counter for unique DisplayID
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
}
// SetupSuite initializes the database, services, handler, and test data.
func (s *CampaignHandlerTestSuite) SetupSuite() {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err, "failed to open SQLite test database")
s.Require().NoError(db.AutoMigrate(
&model.Account{},
&model.Contact{},
&model.Conversation{},
&model.Inbox{},
&model.Message{},
&campaign.Campaign{},
), "failed to auto-migrate models")
s.db = db
// Wire repos → services → handler
campaignRepo := repository.NewCampaignRepo(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)
// Setup router
r := gin.New()
r.Use(gin.Recovery(), s.mockAuthMiddleware())
accountGroup := r.Group("/api/v1/accounts/:account_id")
{
campaigns := accountGroup.Group("/campaigns")
{
campaigns.GET("", s.handler.List)
campaigns.GET("/:campaign_id", s.handler.Get)
campaigns.POST("", s.handler.Create)
campaigns.PATCH("/:campaign_id", s.handler.Update)
campaigns.PUT("/:campaign_id", s.handler.Update)
campaigns.DELETE("/:campaign_id", s.handler.Delete)
campaigns.POST("/:campaign_id/start", s.handler.Start)
campaigns.POST("/:campaign_id/stop", s.handler.Stop)
}
}
s.router = r
// Create test account
s.account = &model.Account{Name: "CampaignTestOrg", Locale: "en", Active: true}
s.Require().NoError(db.Create(s.account).Error)
// Create test inbox (channel_id=1, web_widget)
s.inbox = &model.Inbox{AccountID: s.account.ID, Name: "CampaignTestInbox", ChannelType: "web_widget", ChannelID: 1}
s.Require().NoError(db.Create(s.inbox).Error)
}
func (s *CampaignHandlerTestSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
sqlDB.Close()
}
}
// 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")
s.displayIDCounter = 0
// Re-seed base data
s.account = &model.Account{Name: "CampaignTestOrg", Locale: "en", Active: true}
s.Require().NoError(s.db.Create(s.account).Error)
s.inbox = &model.Inbox{AccountID: s.account.ID, Name: "CampaignTestInbox", ChannelType: "web_widget", ChannelID: 1}
s.Require().NoError(s.db.Create(s.inbox).Error)
}
// mockAuthMiddleware sets account_id in the Gin context via the URL param.
// Since getAccountID reads account_id from the URL param first,
// the :account_id route param will provide it automatically.
// We also set user_id for completeness.
func (s *CampaignHandlerTestSuite) mockAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("user_id", uint(1))
c.Next()
}
}
// Helper: build account URL prefix
func (s *CampaignHandlerTestSuite) accountURL() string {
return "/api/v1/accounts/" + strconv.FormatUint(uint64(s.account.ID), 10) + "/campaigns"
}
func (s *CampaignHandlerTestSuite) seedInbox(channelType string) *model.Inbox {
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Campaign " + channelType + " Inbox", ChannelType: channelType, ChannelID: 1}
s.Require().NoError(s.db.Create(inbox).Error)
return inbox
}
// Helper: seed a campaign directly into the DB for Get/List/Delete/Update tests
func (s *CampaignHandlerTestSuite) seedCampaign(title, message, campaignType string) *campaign.Campaign {
c := &campaign.Campaign{
AccountID: s.account.ID,
InboxID: s.inbox.ID,
DisplayID: s.nextDisplayID(),
Title: title,
Message: message,
CampaignStatus: campaign.CampaignStatusActive,
CampaignType: campaign.CampaignType(campaignType),
Audience: "{}",
TriggerRules: "{}",
TemplateParams: "{}",
Enabled: true,
}
s.Require().NoError(s.db.Create(c).Error)
return c
}
// ========== List Tests ==========
func (s *CampaignHandlerTestSuite) TestList_Success() {
s.seedCampaign("Test Campaign 1", "Hello world", "ongoing")
s.seedCampaign("Test Campaign 2", "Welcome", "one_off")
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", s.accountURL()+"?page=1&per_page=25", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Len(resp, 2)
s.NotContains(resp[0], "success")
s.NotContains(resp[0], "data")
s.Contains(resp[0], "inbox")
}
func (s *CampaignHandlerTestSuite) TestList_Empty() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", s.accountURL()+"?page=1&per_page=25", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Empty(resp)
}
func (s *CampaignHandlerTestSuite) TestList_Unauthorized() {
// Route without :account_id param → getAccountID returns 0
r := gin.New()
r.Use(gin.Recovery())
r.GET("/api/v1/accounts/campaigns", s.handler.List) // no :account_id param
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/campaigns?page=1&per_page=25", nil)
r.ServeHTTP(w, req)
s.Equal(http.StatusUnauthorized, w.Code)
}
// ========== Get Tests ==========
func (s *CampaignHandlerTestSuite) TestGet_Success() {
c := s.seedCampaign("GetTest Campaign", "Test message", "ongoing")
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", s.accountURL()+"/"+strconv.FormatUint(uint64(c.DisplayID), 10), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.NotContains(resp, "success")
s.NotContains(resp, "data")
s.Equal(c.Title, resp["title"])
s.Equal(float64(c.DisplayID), resp["id"])
s.Contains(resp, "inbox")
}
func (s *CampaignHandlerTestSuite) TestGet_NotFound() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", s.accountURL()+"/99999", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusNotFound, w.Code)
}
func (s *CampaignHandlerTestSuite) TestGet_InvalidID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", s.accountURL()+"/abc", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *CampaignHandlerTestSuite) TestGet_Unauthorized() {
r := gin.New()
r.Use(gin.Recovery())
r.GET("/api/v1/campaigns/:campaign_id", s.handler.Get) // no :account_id param
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/campaigns/1", nil)
r.ServeHTTP(w, req)
s.Equal(http.StatusUnauthorized, w.Code)
}
// ========== Create Tests ==========
func (s *CampaignHandlerTestSuite) TestCreate_Success() {
smsInbox := s.seedInbox("Channel::Sms")
body := map[string]interface{}{
"inbox_id": smsInbox.ID,
"title": "New Campaign",
"message": "Hello from campaign",
"enabled": true,
"scheduled_at": "2026-06-07T10:30:00Z",
"audience": []map[string]interface{}{{"type": "Label", "id": 1}},
"trigger_rules": map[string]interface{}{"url": "https://example.com"},
"template_params": map[string]interface{}{"name": "value"},
}
bodyBytes := marshalNested("campaign", body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL(), bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.NotContains(resp, "success")
s.NotContains(resp, "data")
s.Equal("New Campaign", resp["title"])
s.Equal(float64(s.account.ID), resp["account_id"])
s.Equal("one_off", resp["campaign_type"])
s.Equal(float64(1780828200), resp["scheduled_at"])
s.Greater(resp["id"].(float64), float64(0))
s.NotNil(resp["inbox"])
s.Contains(resp, "audience")
s.Contains(resp, "template_params")
}
func (s *CampaignHandlerTestSuite) TestCreate_LiveChatDefaultsOngoingWithoutCampaignType() {
body := map[string]interface{}{
"inbox_id": s.inbox.ID,
"title": "Live Chat Campaign",
"message": "Hello from live chat",
"enabled": true,
"scheduled_at": "2026-06-07T10:30:00Z",
"trigger_rules": map[string]interface{}{"url": "https://example.com", "time_on_page": 10},
}
bodyBytes := marshalNested("campaign", body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL(), bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal("Live Chat Campaign", resp["title"])
s.Equal("ongoing", resp["campaign_type"])
s.NotContains(resp, "scheduled_at")
s.NotContains(resp, "audience")
}
func (s *CampaignHandlerTestSuite) TestCreate_ValidationError() {
// Missing required fields (title, message, inbox_id)
body := map[string]interface{}{
"campaign_type": "ongoing",
}
bodyBytes := marshalNested("campaign", body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL(), bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Validation failure → service returns error → handleServiceError → 400 or 500
s.True(w.Code == http.StatusBadRequest || w.Code == http.StatusUnprocessableEntity)
}
func (s *CampaignHandlerTestSuite) TestCreate_InvalidJSON() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL(), bytes.NewReader([]byte("invalid json")))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *CampaignHandlerTestSuite) TestCreate_Unauthorized() {
r := gin.New()
r.Use(gin.Recovery())
r.POST("/api/v1/campaigns", s.handler.Create) // no :account_id param
body := map[string]interface{}{
"title": "Test",
"message": "msg",
"campaign_type": "ongoing",
}
bodyBytes := marshalNested("campaign", body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/campaigns", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
s.Equal(http.StatusUnauthorized, w.Code)
}
// ========== Update Tests ==========
func (s *CampaignHandlerTestSuite) TestUpdate_Success() {
c := s.seedCampaign("Original Title", "Original message", "ongoing")
smsInbox := s.seedInbox("Channel::Sms")
body := map[string]interface{}{
"title": "Updated Title",
"message": "Updated message",
"inbox_id": smsInbox.ID,
"scheduled_at": "2026-06-07T10:30:00Z",
}
bodyBytes := marshalNested("campaign", body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", s.accountURL()+"/"+strconv.FormatUint(uint64(c.DisplayID), 10), bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.NotContains(resp, "success")
s.Equal("Updated Title", resp["title"])
s.Equal("one_off", resp["campaign_type"])
s.Equal(float64(1780828200), resp["scheduled_at"])
var updated campaign.Campaign
s.Require().NoError(s.db.First(&updated, c.ID).Error)
s.Equal(smsInbox.ID, updated.InboxID)
s.Require().NotNil(updated.ScheduledAt)
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",
}
bodyBytes := marshalNested("campaign", body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", s.accountURL()+"/99999", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusNotFound, w.Code)
}
func (s *CampaignHandlerTestSuite) TestUpdate_InvalidID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", s.accountURL()+"/abc", bytes.NewReader([]byte(`{"title":"X"}`)))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *CampaignHandlerTestSuite) TestUpdate_InvalidJSON() {
c := s.seedCampaign("Original Title", "Original message", "ongoing")
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", s.accountURL()+"/"+strconv.FormatUint(uint64(c.ID), 10), bytes.NewReader([]byte("invalid")))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *CampaignHandlerTestSuite) TestUpdate_Unauthorized() {
r := gin.New()
r.Use(gin.Recovery())
r.PUT("/api/v1/campaigns/:campaign_id", s.handler.Update) // no :account_id param
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/v1/campaigns/1", bytes.NewReader([]byte(`{"title":"X"}`)))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
s.Equal(http.StatusUnauthorized, w.Code)
}
// ========== Delete Tests ==========
func (s *CampaignHandlerTestSuite) TestDelete_Success() {
c := s.seedCampaign("DeleteTest Campaign", "Test message", "ongoing")
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", s.accountURL()+"/"+strconv.FormatUint(uint64(c.DisplayID), 10), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
// Verify the campaign is soft-deleted
var count int64
s.db.Model(&campaign.Campaign{}).Where("id = ? AND deleted_at IS NULL", c.ID).Count(&count)
s.Equal(int64(0), count)
}
func (s *CampaignHandlerTestSuite) TestDelete_NotFound() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", s.accountURL()+"/99999", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusNotFound, w.Code)
}
func (s *CampaignHandlerTestSuite) TestDelete_InvalidID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", s.accountURL()+"/abc", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *CampaignHandlerTestSuite) TestDelete_Unauthorized() {
r := gin.New()
r.Use(gin.Recovery())
r.DELETE("/api/v1/campaigns/:campaign_id", s.handler.Delete) // no :account_id param
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/v1/campaigns/1", nil)
r.ServeHTTP(w, req)
s.Equal(http.StatusUnauthorized, w.Code)
}
// ========== Start Tests ==========
func (s *CampaignHandlerTestSuite) TestStart_Success() {
c := s.seedCampaign("StartTest Campaign", "Test message", "ongoing")
// Ensure campaign is enabled (default is true)
s.db.Model(&campaign.Campaign{}).Where("id = ?", c.ID).Update("enabled", true)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(c.ID), 10)+"/start", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp struct {
Success bool `json:"success"`
Data map[string]interface{} `json:"data"`
}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp.Success)
s.Equal("campaign triggered successfully", resp.Data["message"])
}
func (s *CampaignHandlerTestSuite) TestStart_NotFound() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/99999/start", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusNotFound, w.Code)
}
func (s *CampaignHandlerTestSuite) TestStart_InvalidID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/abc/start", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *CampaignHandlerTestSuite) TestStart_Unauthorized() {
r := gin.New()
r.Use(gin.Recovery())
r.POST("/api/v1/campaigns/:campaign_id/start", s.handler.Start) // no :account_id param
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/campaigns/1/start", nil)
r.ServeHTTP(w, req)
s.Equal(http.StatusUnauthorized, w.Code)
}
// ========== Stop Tests ==========
func (s *CampaignHandlerTestSuite) TestStop_Success() {
c := s.seedCampaign("StopTest Campaign", "Test message", "ongoing")
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(c.ID), 10)+"/stop", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp struct {
Success bool `json:"success"`
Data map[string]interface{} `json:"data"`
}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.True(resp.Success)
s.Equal("campaign stopped successfully", resp.Data["message"])
// Verify campaign status changed to completed
var updated campaign.Campaign
s.db.First(&updated, c.ID)
s.Equal(campaign.CampaignStatusCompleted, updated.CampaignStatus)
}
func (s *CampaignHandlerTestSuite) TestStop_NotFound() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/99999/stop", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusNotFound, w.Code)
}
func (s *CampaignHandlerTestSuite) TestStop_InvalidID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/abc/stop", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *CampaignHandlerTestSuite) TestStop_Unauthorized() {
r := gin.New()
r.Use(gin.Recovery())
r.POST("/api/v1/campaigns/:campaign_id/stop", s.handler.Stop) // no :account_id param
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/campaigns/1/stop", nil)
r.ServeHTTP(w, req)
s.Equal(http.StatusUnauthorized, w.Code)
}
// ========== Lifecycle: Start then Stop ==========
func (s *CampaignHandlerTestSuite) TestLifecycle_StartThenStop() {
c := s.seedCampaign("Lifecycle Campaign", "Hello lifecycle", "one_off")
// Start
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(c.ID), 10)+"/start", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
// Stop
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", s.accountURL()+"/"+strconv.FormatUint(uint64(c.ID), 10)+"/stop", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
// Verify campaign is completed
var updated campaign.Campaign
s.db.First(&updated, c.ID)
s.Equal(campaign.CampaignStatusCompleted, updated.CampaignStatus)
}
// ========== Full CRUD Lifecycle ==========
func (s *CampaignHandlerTestSuite) TestCRUD_FullLifecycle() {
// Create
createBody := map[string]interface{}{
"inbox_id": s.inbox.ID,
"title": "Lifecycle Campaign",
"message": "Test lifecycle message",
"campaign_type": "one_off",
"enabled": true,
}
createBytes := marshalNested("campaign", createBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL(), bytes.NewReader(createBytes))
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))
createdID := uint(createResp["id"].(float64))
// Get
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", s.accountURL()+"/"+strconv.FormatUint(uint64(createdID), 10), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var getResp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &getResp))
s.Equal("Lifecycle Campaign", getResp["title"])
// Update
updateBody := map[string]interface{}{
"title": "Updated Lifecycle",
"message": "Updated lifecycle message",
}
updateBytes := marshalNested("campaign", updateBody)
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH", s.accountURL()+"/"+strconv.FormatUint(uint64(createdID), 10), bytes.NewReader(updateBytes))
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.Equal("Updated Lifecycle", updateResp["title"])
// List (should include our campaign)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", s.accountURL()+"?page=1&per_page=25", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var listResp []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &listResp))
s.GreaterOrEqual(len(listResp), 1)
// Delete
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", s.accountURL()+"/"+strconv.FormatUint(uint64(createdID), 10), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
// Get after delete → should be not found (soft delete)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", s.accountURL()+"/"+strconv.FormatUint(uint64(createdID), 10), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusNotFound, w.Code)
}
// ========== Cross-Account Isolation ==========
func (s *CampaignHandlerTestSuite) TestGet_DifferentAccount() {
// Create another account and campaign
otherAccount := &model.Account{Name: "OtherOrg", Locale: "en", Active: true}
s.Require().NoError(s.db.Create(otherAccount).Error)
otherInbox := &model.Inbox{AccountID: otherAccount.ID, Name: "OtherInbox", ChannelType: "web_widget", ChannelID: 2}
s.Require().NoError(s.db.Create(otherInbox).Error)
otherCampaign := &campaign.Campaign{
AccountID: otherAccount.ID,
InboxID: otherInbox.ID,
DisplayID: s.nextDisplayID(),
Title: "Other Account Campaign",
Message: "Not visible",
CampaignStatus: campaign.CampaignStatusActive,
CampaignType: campaign.CampaignTypeOngoing,
Audience: "{}",
TriggerRules: "{}",
TemplateParams: "{}",
Enabled: true,
}
s.Require().NoError(s.db.Create(otherCampaign).Error)
// Try to get other account's campaign using our account's URL
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", s.accountURL()+"/"+strconv.FormatUint(uint64(otherCampaign.ID), 10), nil)
s.router.ServeHTTP(w, req)
// Should be not found because it belongs to a different account
s.Equal(http.StatusNotFound, w.Code)
}
// ========== Run the suite ==========
func TestCampaignHandlerTestSuite(t *testing.T) {
suite.Run(t, new(CampaignHandlerTestSuite))
}