package campaign import ( "context" "encoding/json" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" ) func newTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) err = db.AutoMigrate( &Campaign{}, &model.Conversation{}, &model.Message{}, &model.Inbox{}, &model.Contact{}, ) require.NoError(t, err) return db } // --- Model Tests --- func TestCampaign_TableName(t *testing.T) { assert.Equal(t, "campaigns", Campaign{}.TableName()) } func TestCampaignStatus_Constants(t *testing.T) { assert.Equal(t, CampaignStatus("active"), CampaignStatusActive) assert.Equal(t, CampaignStatus("completed"), CampaignStatusCompleted) assert.Equal(t, CampaignStatus("processing"), CampaignStatusProcessing) } func TestCampaignType_Constants(t *testing.T) { assert.Equal(t, CampaignType("ongoing"), CampaignTypeOngoing) assert.Equal(t, CampaignType("one_off"), CampaignTypeOneOff) } // --- Service Tests --- func TestNewCampaignService(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) require.NotNil(t, svc) assert.Equal(t, db, svc.db) assert.Nil(t, svc.dispatcher) } func TestNewCampaignService_WithDispatcher(t *testing.T) { db := newTestDB(t) d := &channel.Dispatcher{} svc := NewCampaignService(db, d) require.NotNil(t, svc) assert.NotNil(t, svc.dispatcher) } func TestCampaignService_Create(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() c := &Campaign{ AccountID: 1, InboxID: 2, Title: "Test Campaign", Message: "Hello!", CampaignStatus: CampaignStatusActive, CampaignType: CampaignTypeOneOff, Enabled: true, } err := svc.Create(ctx, c) require.NoError(t, err) assert.NotZero(t, c.ID) assert.NotZero(t, c.DisplayID) // auto-assigned } func TestCampaignService_Create_WithExistingDisplayID(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() c := &Campaign{ AccountID: 1, InboxID: 2, DisplayID: 42, Title: "Test Campaign", Message: "Hello!", CampaignStatus: CampaignStatusActive, CampaignType: CampaignTypeOneOff, Enabled: true, } err := svc.Create(ctx, c) require.NoError(t, err) assert.Equal(t, uint(42), c.DisplayID) // should not be overwritten } func TestCampaignService_Create_AutoIncrementDisplayID(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() c1 := &Campaign{AccountID: 1, InboxID: 2, Title: "C1", Message: "M1", CampaignType: CampaignTypeOneOff} err := svc.Create(ctx, c1) require.NoError(t, err) c2 := &Campaign{AccountID: 1, InboxID: 2, Title: "C2", Message: "M2", CampaignType: CampaignTypeOneOff} err = svc.Create(ctx, c2) require.NoError(t, err) assert.Equal(t, uint(1), c1.DisplayID) assert.Equal(t, uint(2), c2.DisplayID) } func TestCampaignService_GetByID(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() c := &Campaign{AccountID: 1, InboxID: 2, Title: "Test", Message: "Msg", CampaignType: CampaignTypeOneOff} err := svc.Create(ctx, c) require.NoError(t, err) retrieved, err := svc.GetByID(ctx, c.ID) require.NoError(t, err) assert.Equal(t, c.Title, retrieved.Title) } func TestCampaignService_GetByID_NotFound(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() _, err := svc.GetByID(ctx, 99999) assert.Error(t, err) } func TestCampaignService_ListByAccount(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() for i := 0; i < 3; i++ { err := svc.Create(ctx, &Campaign{ AccountID: 1, InboxID: 2, Title: "C", Message: "M", CampaignType: CampaignTypeOneOff, }) require.NoError(t, err) } campaigns, count, err := svc.ListByAccount(ctx, 1, 0, 25) require.NoError(t, err) assert.Equal(t, int64(3), count) assert.Len(t, campaigns, 3) } func TestCampaignService_ListByAccount_Pagination(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() for i := 0; i < 5; i++ { svc.Create(ctx, &Campaign{AccountID: 1, InboxID: 2, Title: "C", Message: "M", CampaignType: CampaignTypeOneOff}) } campaigns, count, err := svc.ListByAccount(ctx, 1, 0, 2) require.NoError(t, err) assert.Equal(t, int64(5), count) assert.Len(t, campaigns, 2) campaigns2, _, err := svc.ListByAccount(ctx, 1, 2, 2) require.NoError(t, err) assert.Len(t, campaigns2, 2) } func TestCampaignService_ListByInbox(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() svc.Create(ctx, &Campaign{AccountID: 1, InboxID: 10, Title: "C1", Message: "M", CampaignType: CampaignTypeOneOff}) svc.Create(ctx, &Campaign{AccountID: 1, InboxID: 10, Title: "C2", Message: "M", CampaignType: CampaignTypeOneOff}) svc.Create(ctx, &Campaign{AccountID: 1, InboxID: 20, Title: "C3", Message: "M", CampaignType: CampaignTypeOneOff}) campaigns, err := svc.ListByInbox(ctx, 1, 10) require.NoError(t, err) assert.Len(t, campaigns, 2) } func TestCampaignService_Update(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() c := &Campaign{AccountID: 1, InboxID: 2, Title: "Test", Message: "Msg", CampaignType: CampaignTypeOneOff} err := svc.Create(ctx, c) require.NoError(t, err) err = svc.Update(ctx, c.ID, map[string]interface{}{"title": "Updated Title"}) require.NoError(t, err) retrieved, err := svc.GetByID(ctx, c.ID) require.NoError(t, err) assert.Equal(t, "Updated Title", retrieved.Title) } func TestCampaignService_Delete(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() c := &Campaign{AccountID: 1, InboxID: 2, Title: "Test", Message: "Msg", CampaignType: CampaignTypeOneOff} err := svc.Create(ctx, c) require.NoError(t, err) err = svc.Delete(ctx, c.ID) require.NoError(t, err) _, err = svc.GetByID(ctx, c.ID) assert.Error(t, err) // soft deleted } func TestCampaignService_MarkCompleted(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() c := &Campaign{AccountID: 1, InboxID: 2, Title: "Test", Message: "Msg", CampaignType: CampaignTypeOneOff, CampaignStatus: CampaignStatusActive} err := svc.Create(ctx, c) require.NoError(t, err) err = svc.MarkCompleted(ctx, c.ID) require.NoError(t, err) retrieved, err := svc.GetByID(ctx, c.ID) require.NoError(t, err) assert.Equal(t, CampaignStatusCompleted, retrieved.CampaignStatus) } func TestCampaignService_TriggerCampaign_Disabled(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() c := &Campaign{ AccountID: 1, InboxID: 2, Title: "Test", Message: "Msg", CampaignType: CampaignTypeOneOff, CampaignStatus: CampaignStatusActive, Enabled: false, } err := svc.Create(ctx, c) require.NoError(t, err) err = svc.TriggerCampaign(ctx, c.ID) require.NoError(t, err) // disabled campaigns are skipped } func TestCampaignService_TriggerCampaign_NotFound(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() err := svc.TriggerCampaign(ctx, 99999) assert.Error(t, err) } func TestCampaignService_TriggerCampaign_WithAudience(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) ctx := context.Background() // Seed inbox and contacts inbox := &model.Inbox{AccountID: 1, Name: "Test", ChannelType: "api"} err := db.Create(inbox).Error require.NoError(t, err) contact1 := &model.Contact{AccountID: 1, Name: "C1"} contact2 := &model.Contact{AccountID: 1, Name: "C2"} err = db.Create(contact1).Error require.NoError(t, err) err = db.Create(contact2).Error require.NoError(t, err) audienceJSON, _ := json.Marshal(map[string]interface{}{ "contact_ids": []uint{contact1.ID, contact2.ID}, }) c := &Campaign{ AccountID: 1, InboxID: inbox.ID, Title: "Test", Message: "Hello!", CampaignType: CampaignTypeOneOff, CampaignStatus: CampaignStatusActive, Enabled: true, Audience: string(audienceJSON), } err = svc.Create(ctx, c) require.NoError(t, err) err = svc.TriggerCampaign(ctx, c.ID) require.NoError(t, err) // Verify conversations were created var convCount int64 db.Model(&model.Conversation{}).Where("campaign_id = ?", c.ID).Count(&convCount) assert.True(t, convCount > 0, "should have created conversations") } func TestCampaignAudienceContactIDs_ContactIDs(t *testing.T) { audience := `{"contact_ids": [1, 2, 3]}` ids, err := campaignAudienceContactIDs(audience) require.NoError(t, err) assert.Equal(t, []uint{1, 2, 3}, ids) } func TestCampaignAudienceContactIDs_Empty(t *testing.T) { audience := `{}` ids, err := campaignAudienceContactIDs(audience) require.NoError(t, err) assert.Empty(t, ids) } func TestCampaignAudienceContactIDs_FrontendRules(t *testing.T) { audience := `[{"label":"VIP"}]` ids, err := campaignAudienceContactIDs(audience) require.NoError(t, err) assert.Empty(t, ids) } func TestCampaignAudienceContactIDs_InvalidJSON(t *testing.T) { audience := `invalid json` ids, err := campaignAudienceContactIDs(audience) assert.Error(t, err) assert.Nil(t, ids) } // --- CampaignConversationBuilder Tests --- func TestNewCampaignConversationBuilder(t *testing.T) { db := newTestDB(t) b := NewCampaignConversationBuilder(db) require.NotNil(t, b) assert.Nil(t, b.dispatcher) } func TestCampaignConversationBuilder_Build_WithContactIDs(t *testing.T) { db := newTestDB(t) b := NewCampaignConversationBuilder(db) ctx := context.Background() inbox := &model.Inbox{AccountID: 1, Name: "Test", ChannelType: "api"} err := db.Create(inbox).Error require.NoError(t, err) contact := &model.Contact{AccountID: 1, Name: "C1"} err = db.Create(contact).Error require.NoError(t, err) audienceJSON, _ := json.Marshal(map[string]interface{}{ "contact_ids": []uint{contact.ID}, }) c := &Campaign{ AccountID: 1, InboxID: inbox.ID, Title: "Test", Message: "Hello!", CampaignType: CampaignTypeOneOff, CampaignStatus: CampaignStatusActive, Enabled: true, Audience: string(audienceJSON), } err = db.Create(c).Error require.NoError(t, err) err = b.Build(ctx, c) require.NoError(t, err) // Verify conversation and message were created var conv model.Conversation err = db.Where("campaign_id = ?", c.ID).First(&conv).Error require.NoError(t, err) assert.Equal(t, "open", conv.Status) assert.Equal(t, "campaign", conv.ChannelType) var msg model.Message err = db.Where("conversation_id = ?", conv.ID).First(&msg).Error require.NoError(t, err) assert.Equal(t, "Hello!", msg.Content) assert.Equal(t, "outgoing", msg.MessageType) assert.Equal(t, "template", msg.ContentType) } func TestCampaignConversationBuilder_Build_InvalidAudience(t *testing.T) { db := newTestDB(t) b := NewCampaignConversationBuilder(db) ctx := context.Background() c := &Campaign{ AccountID: 1, InboxID: 2, Title: "Test", Message: "Hello!", Audience: "invalid json", } err := b.Build(ctx, c) assert.Error(t, err) assert.Contains(t, err.Error(), "parse audience") } func TestCampaignConversationBuilder_Build_WithSender(t *testing.T) { db := newTestDB(t) b := NewCampaignConversationBuilder(db) ctx := context.Background() inbox := &model.Inbox{AccountID: 1, Name: "Test", ChannelType: "api"} err := db.Create(inbox).Error require.NoError(t, err) contact := &model.Contact{AccountID: 1, Name: "C1"} err = db.Create(contact).Error require.NoError(t, err) senderID := uint(100) audienceJSON, _ := json.Marshal(map[string]interface{}{ "contact_ids": []uint{contact.ID}, }) c := &Campaign{ AccountID: 1, InboxID: inbox.ID, Title: "Test", Message: "Hello!", CampaignType: CampaignTypeOneOff, CampaignStatus: CampaignStatusActive, Enabled: true, Audience: string(audienceJSON), SenderID: &senderID, } err = db.Create(c).Error require.NoError(t, err) err = b.Build(ctx, c) require.NoError(t, err) var conv model.Conversation err = db.Where("campaign_id = ?", c.ID).First(&conv).Error require.NoError(t, err) assert.Equal(t, &senderID, conv.AssigneeID) } // --- CampaignListener Tests --- func TestNewCampaignListener(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) require.NotNil(t, l) assert.Equal(t, "campaign_listener", l.Name()) } func TestCampaignListener_OnEvent_Unknown(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() event := &channel.ChannelEvent{Type: "unknown.event", Data: map[string]interface{}{}} err := l.OnEvent(ctx, event) assert.NoError(t, err) } func TestCampaignListener_OnCampaignTriggered(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() inbox := &model.Inbox{AccountID: 1, Name: "Test", ChannelType: "api"} err := db.Create(inbox).Error require.NoError(t, err) c := &Campaign{ AccountID: 1, InboxID: inbox.ID, Title: "Test", Message: "Hi", CampaignType: CampaignTypeOneOff, Enabled: true, Audience: `{"contact_ids":[]}`, } err = svc.Create(ctx, c) require.NoError(t, err) event := &channel.ChannelEvent{ Type: "campaign.triggered", AccountID: 1, Data: map[string]interface{}{"campaign_id": float64(c.ID)}, } err = l.OnEvent(ctx, event) assert.NoError(t, err) } func TestCampaignListener_OnCampaignTriggered_NoCampaignID(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() event := &channel.ChannelEvent{ Type: "campaign.triggered", Data: map[string]interface{}{}, } err := l.OnEvent(ctx, event) assert.Error(t, err) assert.Contains(t, err.Error(), "campaign_id not found") } func TestCampaignListener_OnCampaignTriggered_IntID(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() inbox := &model.Inbox{AccountID: 1, Name: "Test", ChannelType: "api"} err := db.Create(inbox).Error require.NoError(t, err) c := &Campaign{ AccountID: 1, InboxID: inbox.ID, Title: "Test", Message: "Hi", CampaignType: CampaignTypeOneOff, Enabled: true, Audience: `{"contact_ids":[]}`, } err = svc.Create(ctx, c) require.NoError(t, err) event := &channel.ChannelEvent{ Type: "campaign.triggered", Data: map[string]interface{}{"campaign_id": int(c.ID)}, } err = l.OnEvent(ctx, event) assert.NoError(t, err) } func TestCampaignListener_OnCampaignTriggered_UintID(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() inbox := &model.Inbox{AccountID: 1, Name: "Test", ChannelType: "api"} err := db.Create(inbox).Error require.NoError(t, err) c := &Campaign{ AccountID: 1, InboxID: inbox.ID, Title: "Test", Message: "Hi", CampaignType: CampaignTypeOneOff, Enabled: true, Audience: `{"contact_ids":[]}`, } err = svc.Create(ctx, c) require.NoError(t, err) event := &channel.ChannelEvent{ Type: "campaign.triggered", Data: map[string]interface{}{"campaign_id": uint(c.ID)}, } err = l.OnEvent(ctx, event) assert.NoError(t, err) } func TestCampaignListener_OnCampaignTriggered_InvalidType(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() event := &channel.ChannelEvent{ Type: "campaign.triggered", Data: map[string]interface{}{"campaign_id": "not-a-number"}, } err := l.OnEvent(ctx, event) assert.Error(t, err) assert.Contains(t, err.Error(), "unexpected campaign_id type") } func TestCampaignListener_OnConversationResolved_NonCampaignChannel(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() conv := &model.Conversation{Base: model.Base{ID: 1}, AccountID: 10, InboxID: 20, ChannelType: "api"} event := &channel.ChannelEvent{ Type: channel.EventConversationResolved, AccountID: 10, InboxID: 20, Data: map[string]interface{}{"conversation": conv}, } err := l.OnEvent(ctx, event) assert.NoError(t, err) } func TestCampaignListener_OnConversationResolved_CampaignChannel_NoCampaignID(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() conv := &model.Conversation{Base: model.Base{ID: 1}, AccountID: 10, InboxID: 20, ChannelType: "campaign"} event := &channel.ChannelEvent{ Type: channel.EventConversationResolved, AccountID: 10, InboxID: 20, Data: map[string]interface{}{"conversation": conv}, } err := l.OnEvent(ctx, event) assert.NoError(t, err) // no campaign_id — returns nil } func TestCampaignListener_OnConversationResolved_OneOffCampaign(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() c := &Campaign{ AccountID: 1, InboxID: 2, Title: "Test", Message: "Hi", CampaignType: CampaignTypeOneOff, CampaignStatus: CampaignStatusActive, } err := svc.Create(ctx, c) require.NoError(t, err) conv := &model.Conversation{Base: model.Base{ID: 1}, AccountID: 1, InboxID: 2, ChannelType: "campaign"} event := &channel.ChannelEvent{ Type: channel.EventConversationResolved, AccountID: 1, InboxID: 2, Data: map[string]interface{}{ "conversation": conv, "campaign_id": float64(c.ID), }, } err = l.OnEvent(ctx, event) require.NoError(t, err) // Campaign should be marked completed retrieved, err := svc.GetByID(ctx, c.ID) require.NoError(t, err) assert.Equal(t, CampaignStatusCompleted, retrieved.CampaignStatus) } func TestCampaignListener_OnConversationResolved_OngoingCampaign(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() c := &Campaign{ AccountID: 1, InboxID: 2, Title: "Test", Message: "Hi", CampaignType: CampaignTypeOngoing, CampaignStatus: CampaignStatusActive, } err := svc.Create(ctx, c) require.NoError(t, err) conv := &model.Conversation{Base: model.Base{ID: 1}, AccountID: 1, InboxID: 2, ChannelType: "campaign"} event := &channel.ChannelEvent{ Type: channel.EventConversationResolved, AccountID: 1, InboxID: 2, Data: map[string]interface{}{ "conversation": conv, "campaign_id": float64(c.ID), }, } err = l.OnEvent(ctx, event) require.NoError(t, err) // Ongoing campaigns should NOT be marked completed retrieved, err := svc.GetByID(ctx, c.ID) require.NoError(t, err) assert.Equal(t, CampaignStatusActive, retrieved.CampaignStatus) } func TestCampaignListener_OnConversationResolved_NoConversation(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() event := &channel.ChannelEvent{ Type: channel.EventConversationResolved, Data: map[string]interface{}{}, } err := l.OnEvent(ctx, event) assert.NoError(t, err) // no conversation — returns nil } func TestCampaignListener_OnConversationResolved_CampaignNotFound(t *testing.T) { db := newTestDB(t) svc := NewCampaignService(db) l := NewCampaignListener(db, svc) ctx := context.Background() conv := &model.Conversation{Base: model.Base{ID: 1}, AccountID: 1, InboxID: 2, ChannelType: "campaign"} event := &channel.ChannelEvent{ Type: channel.EventConversationResolved, Data: map[string]interface{}{ "conversation": conv, "campaign_id": float64(99999), }, } err := l.OnEvent(ctx, event) assert.NoError(t, err) // campaign not found — returns nil } func TestCampaign_ScheduledAt(t *testing.T) { now := time.Now() c := Campaign{ScheduledAt: &now} assert.Equal(t, &now, c.ScheduledAt) } func TestCampaign_TriggerOnlyDuringBusinessHours(t *testing.T) { c := Campaign{TriggerOnlyDuringBusinessHours: true} assert.True(t, c.TriggerOnlyDuringBusinessHours) }