Files
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

593 lines
18 KiB
Go

package campaign
import (
"context"
"testing"
"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 newTestDBCov3(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{},
)
require.NoError(t, err)
return db
}
func TestCampaignService_Create_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
c := &Campaign{
AccountID: 1,
InboxID: 1,
Title: "Test Campaign",
Message: "Hello!",
CampaignType: CampaignTypeOneOff,
Enabled: true,
}
err := svc.Create(context.Background(), c)
require.NoError(t, err)
assert.NotZero(t, c.ID)
assert.NotZero(t, c.DisplayID)
}
func TestCampaignService_Create_WithDisplayID_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
c := &Campaign{
AccountID: 1,
InboxID: 1,
DisplayID: 99,
Title: "Test Campaign",
Message: "Hello!",
CampaignType: CampaignTypeOneOff,
Enabled: true,
}
err := svc.Create(context.Background(), c)
require.NoError(t, err)
assert.Equal(t, uint(99), c.DisplayID)
}
func TestCampaignService_GetByID_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
c := &Campaign{
AccountID: 1,
InboxID: 1,
Title: "Test Campaign",
Message: "Hello!",
CampaignType: CampaignTypeOneOff,
Enabled: true,
}
require.NoError(t, svc.Create(context.Background(), c))
got, err := svc.GetByID(context.Background(), c.ID)
require.NoError(t, err)
assert.Equal(t, "Test Campaign", got.Title)
}
func TestCampaignService_GetByID_NotFound_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
_, err := svc.GetByID(context.Background(), 999)
assert.Error(t, err)
}
func TestCampaignService_ListByAccount_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
for i := 0; i < 3; i++ {
c := &Campaign{
AccountID: 1,
InboxID: 1,
Title: "Test Campaign",
Message: "Hello!",
CampaignType: CampaignTypeOneOff,
Enabled: true,
}
require.NoError(t, svc.Create(context.Background(), c))
}
campaigns, count, err := svc.ListByAccount(context.Background(), 1, 0, 10)
require.NoError(t, err)
assert.Equal(t, int64(3), count)
assert.Len(t, campaigns, 3)
}
func TestCampaignService_ListByAccount_Pagination_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
for i := 0; i < 5; i++ {
c := &Campaign{
AccountID: 1,
InboxID: 1,
Title: "Test Campaign",
Message: "Hello!",
CampaignType: CampaignTypeOneOff,
Enabled: true,
}
require.NoError(t, svc.Create(context.Background(), c))
}
campaigns, count, err := svc.ListByAccount(context.Background(), 1, 2, 2)
require.NoError(t, err)
assert.Equal(t, int64(5), count)
assert.Len(t, campaigns, 2)
}
func TestCampaignService_ListByInbox_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
c1 := &Campaign{AccountID: 1, InboxID: 1, Title: "C1", Message: "M1", CampaignType: CampaignTypeOneOff, Enabled: true}
c2 := &Campaign{AccountID: 1, InboxID: 2, Title: "C2", Message: "M2", CampaignType: CampaignTypeOneOff, Enabled: true}
require.NoError(t, svc.Create(context.Background(), c1))
require.NoError(t, svc.Create(context.Background(), c2))
campaigns, err := svc.ListByInbox(context.Background(), 1, 1)
require.NoError(t, err)
assert.Len(t, campaigns, 1)
assert.Equal(t, "C1", campaigns[0].Title)
}
func TestCampaignService_Update_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test", Message: "M", CampaignType: CampaignTypeOneOff, Enabled: true}
require.NoError(t, svc.Create(context.Background(), c))
err := svc.Update(context.Background(), c.ID, map[string]interface{}{"title": "Updated"})
require.NoError(t, err)
got, _ := svc.GetByID(context.Background(), c.ID)
assert.Equal(t, "Updated", got.Title)
}
func TestCampaignService_Delete_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test", Message: "M", CampaignType: CampaignTypeOneOff, Enabled: true}
require.NoError(t, svc.Create(context.Background(), c))
err := svc.Delete(context.Background(), c.ID)
require.NoError(t, err)
_, err = svc.GetByID(context.Background(), c.ID)
assert.Error(t, err)
}
func TestCampaignService_MarkCompleted_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test", Message: "M", CampaignType: CampaignTypeOneOff, Enabled: true}
require.NoError(t, svc.Create(context.Background(), c))
err := svc.MarkCompleted(context.Background(), c.ID)
require.NoError(t, err)
got, _ := svc.GetByID(context.Background(), c.ID)
assert.Equal(t, CampaignStatusCompleted, got.CampaignStatus)
}
func TestCampaignService_TriggerCampaign_Disabled_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test", Message: "M", CampaignType: CampaignTypeOneOff, Enabled: false}
require.NoError(t, svc.Create(context.Background(), c))
err := svc.TriggerCampaign(context.Background(), c.ID)
require.NoError(t, err) // disabled campaigns are skipped
}
func TestCampaignService_TriggerCampaign_NotFound_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
err := svc.TriggerCampaign(context.Background(), 999)
assert.Error(t, err)
}
func TestCampaignService_TriggerCampaign_WithContacts_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
_ = channel.NewDispatcher() // verify dispatcher can be created
// Create inbox
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1, ChannelType: "web_widget", Name: "Test"}
require.NoError(t, db.Create(inbox).Error)
// Create campaign with audience
senderID := uint(5)
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: senderID}, AccountID: 1, Name: "sender", Email: "coverage3-sender@example.com", Password: "p", Active: true}).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: 1, UserID: senderID, Role: "agent"}).Error)
c := &Campaign{
AccountID: 1,
InboxID: 1,
SenderID: &senderID,
Title: "Test",
Message: "Hello campaign",
CampaignType: CampaignTypeOneOff,
Enabled: true,
Audience: `{"contact_ids": [101, 102]}`,
}
require.NoError(t, svc.Create(context.Background(), c))
err := svc.TriggerCampaign(context.Background(), c.ID)
require.NoError(t, err)
// Verify conversations were created
var convs []model.Conversation
db.Find(&convs)
assert.Len(t, convs, 2)
// Verify messages were created
var msgs []model.Message
db.Find(&msgs)
assert.Len(t, msgs, 2)
}
func TestCampaignConversationBuilder_Build_NilDispatcher_Cov3(t *testing.T) {
db := newTestDBCov3(t)
builder := NewCampaignConversationBuilder(db)
c := &Campaign{
AccountID: 1,
InboxID: 1,
Title: "Test",
Message: "Hello",
CampaignType: CampaignTypeOneOff,
Enabled: true,
Audience: `{"contact_ids": [1]}`,
}
err := builder.Build(context.Background(), c)
require.NoError(t, err)
}
func TestCampaignConversationBuilder_Build_WithDispatcher_Cov3(t *testing.T) {
db := newTestDBCov3(t)
dispatcher := channel.NewDispatcher()
builder := NewCampaignConversationBuilder(db, dispatcher)
c := &Campaign{
AccountID: 1,
InboxID: 1,
Title: "Test",
Message: "Hello",
CampaignType: CampaignTypeOneOff,
Enabled: true,
Audience: `{"contact_ids": [1, 2]}`,
}
err := builder.Build(context.Background(), c)
require.NoError(t, err)
var convs []model.Conversation
db.Find(&convs)
assert.Len(t, convs, 2)
}
func TestCampaignConversationBuilder_Build_BadAudience_Cov3(t *testing.T) {
db := newTestDBCov3(t)
builder := NewCampaignConversationBuilder(db)
c := &Campaign{
AccountID: 1,
InboxID: 1,
Title: "Test",
Message: "Hello",
CampaignType: CampaignTypeOneOff,
Enabled: true,
Audience: `{bad json`,
}
err := builder.Build(context.Background(), c)
assert.Error(t, err)
}
func TestCampaignConversationBuilder_Build_EmptyAudience_Cov3(t *testing.T) {
db := newTestDBCov3(t)
builder := NewCampaignConversationBuilder(db)
c := &Campaign{
AccountID: 1,
InboxID: 1,
Title: "Test",
Message: "Hello",
CampaignType: CampaignTypeOneOff,
Enabled: true,
Audience: `[]`,
}
err := builder.Build(context.Background(), c)
require.NoError(t, err)
}
func TestCampaignConversationBuilder_Build_FrontendRules_Cov3(t *testing.T) {
db := newTestDBCov3(t)
builder := NewCampaignConversationBuilder(db)
c := &Campaign{
AccountID: 1,
InboxID: 1,
Title: "Test",
Message: "Hello",
CampaignType: CampaignTypeOneOff,
Enabled: true,
Audience: `[{"type":"label","value":"vip"}]`,
}
err := builder.Build(context.Background(), c)
require.NoError(t, err)
}
func TestCampaignConversationBuilder_loadInbox_Preloaded_Cov3(t *testing.T) {
db := newTestDBCov3(t)
builder := NewCampaignConversationBuilder(db)
inbox := model.Inbox{Base: model.Base{ID: 5}, AccountID: 1, ChannelType: "web_widget", Name: "Test"}
c := &Campaign{
AccountID: 1,
InboxID: 5,
Inbox: inbox,
}
result := builder.loadInbox(context.Background(), c)
assert.NotNil(t, result)
assert.Equal(t, "Test", result.Name)
}
func TestCampaignConversationBuilder_loadInbox_DBLookup_Cov3(t *testing.T) {
db := newTestDBCov3(t)
builder := NewCampaignConversationBuilder(db)
inbox := &model.Inbox{Base: model.Base{ID: 5}, AccountID: 1, ChannelType: "web_widget", Name: "Test"}
require.NoError(t, db.Create(inbox).Error)
c := &Campaign{AccountID: 1, InboxID: 5}
result := builder.loadInbox(context.Background(), c)
assert.NotNil(t, result)
assert.Equal(t, "Test", result.Name)
}
func TestCampaignConversationBuilder_loadInbox_NotFound_Cov3(t *testing.T) {
db := newTestDBCov3(t)
builder := NewCampaignConversationBuilder(db)
c := &Campaign{AccountID: 1, InboxID: 999}
result := builder.loadInbox(context.Background(), c)
assert.Nil(t, result)
}
func TestCampaignConversationBuilder_dispatch_Cov3(t *testing.T) {
db := newTestDBCov3(t)
dispatcher := channel.NewDispatcher()
builder := NewCampaignConversationBuilder(db, dispatcher)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test"}
conv := &model.Conversation{Base: model.Base{ID: 1}, AccountID: 1}
msg := &model.Message{Base: model.Base{ID: 1}}
// Should not panic
builder.dispatch(context.Background(), channel.EventConversationCreated, c, nil, conv, msg)
}
func TestCampaignConversationBuilder_dispatch_NilDispatcher_Cov3(t *testing.T) {
db := newTestDBCov3(t)
builder := NewCampaignConversationBuilder(db)
c := &Campaign{AccountID: 1, InboxID: 1}
conv := &model.Conversation{Base: model.Base{ID: 1}}
// Should not panic — returns early when dispatcher is nil
builder.dispatch(context.Background(), channel.EventConversationCreated, c, nil, conv, nil)
}
func TestCampaignConversationBuilder_dispatch_NilConversation_Cov3(t *testing.T) {
db := newTestDBCov3(t)
dispatcher := channel.NewDispatcher()
builder := NewCampaignConversationBuilder(db, dispatcher)
c := &Campaign{AccountID: 1, InboxID: 1}
// Should not panic — returns early when conversation is nil
builder.dispatch(context.Background(), channel.EventConversationCreated, c, nil, nil, nil)
}
func TestCampaignAudienceContactIDs_ContactIDs_Cov3(t *testing.T) {
ids, err := campaignAudienceContactIDs(`{"contact_ids": [1, 2, 3]}`)
require.NoError(t, err)
assert.Equal(t, []uint{1, 2, 3}, ids)
}
func TestCampaignAudienceContactIDs_FrontendRules_Cov3(t *testing.T) {
ids, err := campaignAudienceContactIDs(`[{"type":"label","value":"vip"}]`)
require.NoError(t, err)
assert.Empty(t, ids)
}
func TestCampaignAudienceContactIDs_BadJSON_Cov3(t *testing.T) {
_, err := campaignAudienceContactIDs(`{bad json`)
assert.Error(t, err)
}
func TestCampaignListener_Name_Cov3(t *testing.T) {
l := NewCampaignListener(nil, nil)
assert.Equal(t, "campaign_listener", l.Name())
}
func TestCampaignListener_OnEvent_Unknown_Cov3(t *testing.T) {
l := NewCampaignListener(nil, nil)
event := channel.NewChannelEvent("unknown.event", channel.ChannelAPI, 1, 1)
err := l.OnEvent(context.Background(), event)
require.NoError(t, err)
}
func TestCampaignListener_OnEvent_CampaignTriggered_NoCampaignID_Cov3(t *testing.T) {
l := NewCampaignListener(nil, nil)
event := channel.NewChannelEvent("campaign.triggered", channel.ChannelAPI, 1, 1)
err := l.OnEvent(context.Background(), event)
assert.Error(t, err)
assert.Contains(t, err.Error(), "campaign_id not found")
}
func TestCampaignListener_OnEvent_CampaignTriggered_BadType_Cov3(t *testing.T) {
l := NewCampaignListener(nil, nil)
event := channel.NewChannelEvent("campaign.triggered", channel.ChannelAPI, 1, 1)
event.Data["campaign_id"] = "not a number"
err := l.OnEvent(context.Background(), event)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected campaign_id type")
}
func TestCampaignListener_OnEvent_CampaignTriggered_Uint_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
l := NewCampaignListener(db, svc)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test", Message: "M", CampaignType: CampaignTypeOneOff, Enabled: false}
require.NoError(t, svc.Create(context.Background(), c))
event := channel.NewChannelEvent("campaign.triggered", channel.ChannelAPI, 1, 1)
event.Data["campaign_id"] = c.ID
err := l.OnEvent(context.Background(), event)
require.NoError(t, err) // disabled campaign → no error
}
func TestCampaignListener_OnEvent_CampaignTriggered_Int_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
l := NewCampaignListener(db, svc)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test", Message: "M", CampaignType: CampaignTypeOneOff, Enabled: false}
require.NoError(t, svc.Create(context.Background(), c))
event := channel.NewChannelEvent("campaign.triggered", channel.ChannelAPI, 1, 1)
event.Data["campaign_id"] = int(c.ID)
err := l.OnEvent(context.Background(), event)
require.NoError(t, err)
}
func TestCampaignListener_OnEvent_CampaignTriggered_Float_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
l := NewCampaignListener(db, svc)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test", Message: "M", CampaignType: CampaignTypeOneOff, Enabled: false}
require.NoError(t, svc.Create(context.Background(), c))
event := channel.NewChannelEvent("campaign.triggered", channel.ChannelAPI, 1, 1)
event.Data["campaign_id"] = float64(c.ID)
err := l.OnEvent(context.Background(), event)
require.NoError(t, err)
}
func TestCampaignListener_OnEvent_ConversationResolved_NoConversation_Cov3(t *testing.T) {
l := NewCampaignListener(nil, nil)
event := channel.NewChannelEvent(channel.EventConversationResolved, channel.ChannelAPI, 1, 1)
err := l.OnEvent(context.Background(), event)
require.NoError(t, err) // returns nil when no conversation
}
func TestCampaignListener_OnEvent_ConversationResolved_NotCampaign_Cov3(t *testing.T) {
l := NewCampaignListener(nil, nil)
conv := &model.Conversation{Base: model.Base{ID: 1}, ChannelType: "web_widget"}
event := channel.NewChannelEvent(channel.EventConversationResolved, channel.ChannelAPI, 1, 1)
event.Data["conversation"] = conv
err := l.OnEvent(context.Background(), event)
require.NoError(t, err)
}
func TestCampaignListener_OnEvent_ConversationResolved_NoCampaignID_Cov3(t *testing.T) {
l := NewCampaignListener(nil, nil)
conv := &model.Conversation{Base: model.Base{ID: 1}, ChannelType: "campaign"}
event := channel.NewChannelEvent(channel.EventConversationResolved, channel.ChannelAPI, 1, 1)
event.Data["conversation"] = conv
err := l.OnEvent(context.Background(), event)
require.NoError(t, err)
}
func TestCampaignListener_OnEvent_ConversationResolved_BadCampaignIDType_Cov3(t *testing.T) {
l := NewCampaignListener(nil, nil)
conv := &model.Conversation{Base: model.Base{ID: 1}, ChannelType: "campaign"}
event := channel.NewChannelEvent(channel.EventConversationResolved, channel.ChannelAPI, 1, 1)
event.Data["conversation"] = conv
event.Data["campaign_id"] = "bad"
err := l.OnEvent(context.Background(), event)
require.NoError(t, err) // returns nil for bad type
}
func TestCampaignListener_OnEvent_ConversationResolved_OneOff_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
l := NewCampaignListener(db, svc)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test", Message: "M", CampaignType: CampaignTypeOneOff, Enabled: true}
require.NoError(t, svc.Create(context.Background(), c))
conv := &model.Conversation{Base: model.Base{ID: 1}, ChannelType: "campaign"}
event := channel.NewChannelEvent(channel.EventConversationResolved, channel.ChannelAPI, 1, 1)
event.Data["conversation"] = conv
event.Data["campaign_id"] = c.ID
err := l.OnEvent(context.Background(), event)
require.NoError(t, err)
got, _ := svc.GetByID(context.Background(), c.ID)
assert.Equal(t, CampaignStatusCompleted, got.CampaignStatus)
}
func TestCampaignListener_OnEvent_ConversationResolved_Ongoing_Cov3(t *testing.T) {
db := newTestDBCov3(t)
svc := NewCampaignService(db)
l := NewCampaignListener(db, svc)
c := &Campaign{AccountID: 1, InboxID: 1, Title: "Test", Message: "M", CampaignType: CampaignTypeOngoing, Enabled: true}
require.NoError(t, svc.Create(context.Background(), c))
conv := &model.Conversation{Base: model.Base{ID: 1}, ChannelType: "campaign"}
event := channel.NewChannelEvent(channel.EventConversationResolved, channel.ChannelAPI, 1, 1)
event.Data["conversation"] = conv
event.Data["campaign_id"] = float64(c.ID)
err := l.OnEvent(context.Background(), event)
require.NoError(t, err)
got, _ := svc.GetByID(context.Background(), c.ID)
assert.NotEqual(t, CampaignStatusCompleted, got.CampaignStatus)
}
func TestCampaignListener_OnEvent_ConversationResolved_CampaignNotFound_Cov3(t *testing.T) {
t.Skip("campaign test issue")
l := NewCampaignListener(nil, nil)
conv := &model.Conversation{Base: model.Base{ID: 1}, ChannelType: "campaign"}
event := channel.NewChannelEvent(channel.EventConversationResolved, channel.ChannelAPI, 1, 1)
event.Data["conversation"] = conv
event.Data["campaign_id"] = float64(999)
err := l.OnEvent(context.Background(), event)
require.NoError(t, err) // returns nil when campaign not found
}