735 lines
23 KiB
Go
735 lines
23 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"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/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
|
|
|
|
account *model.Account
|
|
inbox *model.Inbox
|
|
|
|
// Counter for unique DisplayID
|
|
displayIDCounter uint
|
|
}
|
|
|
|
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.Inbox{},
|
|
&campaign.Campaign{},
|
|
), "failed to auto-migrate models")
|
|
|
|
s.db = db
|
|
|
|
// Wire repos → services → handler
|
|
campaignRepo := repository.NewCampaignRepo(db)
|
|
campaignSvc := campaign.NewCampaignService(db)
|
|
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.db.Exec("DELETE FROM campaigns")
|
|
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) 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))
|
|
}
|