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.
925 lines
31 KiB
Go
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))
|
|
}
|