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)) }