Files
gochat/backend/internal/campaign/campaign_test.go
T
Rogeeandrogee 6c78820a1f H-338: close H-335 release blockers (#59)
* H-16: align takeover with channel AI workflow (#2)

* feat(conversations): complete manual AI takeover

* fix(conversations): align AI takeover flow with channel AI

* fix(conversations): close takeover review gaps

---------

Co-authored-by: Rogee <rogee@ipao.vip>

* feat(shangwutong): sync customer names back to channel (#3)

Co-authored-by: Rogee <rogee@ipao.vip>

* fix(shangwutong): close contact sync review gaps (#4)

Co-authored-by: Rogee <rogee@ipao.vip>

* H-28: harden Shangwutong CID sync (#5)

* fix(shangwutong): close contact sync review gaps

* fix(shangwutong): harden CID sync boundaries

---------

Co-authored-by: Rogee <rogee@ipao.vip>

* fix(conversations): sync AI takeover exit in realtime (#6)

Co-authored-by: Rogee <rogee@ipao.vip>

* test(shangwutong): cover CID rename reliability (#7)

Co-authored-by: Rogee <rogee@ipao.vip>

* H-43: fix WEB Captain takeover E2E flow (#8)

* test(shangwutong): cover CID rename reliability

* H-43: fix WEB Captain takeover flow

* H-48: preserve compatible provider model

* H-49: make Captain takeover atomic

* H-50: prevent duplicate widget initialization

---------

Co-authored-by: Rogee <rogee@ipao.vip>

* H-55: make Captain bindings atomic (#9)

Co-authored-by: Rogee <rogee@ipao.vip>

* H-60: harden Captain migration rollback and concurrency

* chore(agent): baseline — uncommitted work from the local directory

* H-335: add safe Captain skills and user deactivation

* H-338: close auth and Captain review blockers

* H-338: close assignment and session races

* H-338: close assignment and websocket invalidation gaps

* H-338: enforce assignment write invariants

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-20 10:21:19 +08:00

774 lines
22 KiB
Go

package campaign
import (
"context"
"encoding/json"
"fmt"
"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.User{},
&model.AccountUser{},
&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++ {
require.NoError(t, 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()
require.NoError(t, svc.Create(ctx, &Campaign{AccountID: 1, InboxID: 10, Title: "C1", Message: "M", CampaignType: CampaignTypeOneOff}))
require.NoError(t, svc.Create(ctx, &Campaign{AccountID: 1, InboxID: 10, Title: "C2", Message: "M", CampaignType: CampaignTypeOneOff}))
require.NoError(t, 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)
sender := &model.User{AccountID: 1, Name: "sender", Email: "campaign-sender@example.com", Password: "p", Active: true}
require.NoError(t, db.Create(sender).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: 1, UserID: sender.ID, Role: "agent"}).Error)
senderID := sender.ID
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)
}
func TestCampaignConversationBuilder_RevalidatesSenderAtExecution(t *testing.T) {
tests := map[string]func(*gorm.DB, *model.User){
"inactive": func(db *gorm.DB, sender *model.User) {
require.NoError(t, db.Model(sender).Update("active", false).Error)
},
"downgraded role": func(db *gorm.DB, sender *model.User) {
require.NoError(t, db.Model(&model.AccountUser{}).
Where("account_id = ? AND user_id = ?", sender.AccountID, sender.ID).
Update("role", "member").Error)
},
}
for name, invalidate := range tests {
t.Run(name, func(t *testing.T) {
db := newTestDB(t)
inbox := &model.Inbox{AccountID: 1, Name: "Test", ChannelType: "api"}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: 1, Name: "C1"}
require.NoError(t, db.Create(contact).Error)
sender := &model.User{AccountID: 1, Name: "sender", Email: name + "@example.com", Password: "p", Active: true}
require.NoError(t, db.Create(sender).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: 1, UserID: sender.ID, Role: "agent"}).Error)
senderID := sender.ID
campaign := &Campaign{AccountID: 1, InboxID: inbox.ID, Message: "Hello!", Audience: fmt.Sprintf(`{"contact_ids":[%d]}`, contact.ID), SenderID: &senderID}
require.NoError(t, db.Create(campaign).Error)
invalidate(db, sender)
require.NoError(t, NewCampaignConversationBuilder(db).Build(context.Background(), campaign))
var count int64
require.NoError(t, db.Model(&model.Conversation{}).Where("campaign_id = ?", campaign.ID).Count(&count).Error)
require.Zero(t, count)
})
}
}
// --- 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)
}