Files
gochat/backend/internal/service/inbox_service_test.go
T

946 lines
37 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
whatsappchannel "github.com/gochat/gochat/internal/channel/whatsapp"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/worker"
)
// ========== Test Setup ==========
// setupInboxServiceTest creates a test DB and InboxService using SQLite in-memory.
// WhatsApp-specific repos (whatsappService, whatsappRepo) are nil since we
// test the non-WhatsApp paths exclusively (web_widget, api channel types).
func setupInboxServiceTest(t *testing.T) (*InboxService, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err, "failed to open SQLite test db")
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.AgentBot{},
&model.Inbox{},
&model.CustomAttributeDefinition{},
&model.AgentBotInbox{},
&model.WebhookSubscription{},
&model.BackgroundJob{},
&model.ChannelShangwutongConfig{},
&channelmodel.ChannelAPI{},
&channelmodel.ChannelWhatsApp{},
), "failed to auto-migrate")
t.Cleanup(func() {
sqlDB, _ := db.DB()
sqlDB.Close()
})
repo := repository.NewInboxRepo(db)
agentBotInboxRepo := repository.NewAgentBotInboxRepo(db)
agentBotRepo := repository.NewAgentBotRepo(db)
webhookSubRepo := repository.NewWebhookSubscriptionRepo(db)
waRepo := whatsappchannel.NewRepository(db)
svc := NewInboxService(repo, agentBotInboxRepo, agentBotRepo, nil, webhookSubRepo, nil, waRepo)
return svc, db
}
type fakeInboxWhatsAppService struct {
healthPayload map[string]interface{}
healthErr error
templates []interface{}
templateErr error
fetchCalls int
webhookURL string
webhookFields []string
webhookErr error
callingStatus string
callingErr error
}
func (f *fakeInboxWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) {
f.fetchCalls++
if f.templateErr != nil {
return nil, f.templateErr
}
return f.templates, nil
}
func (f *fakeInboxWhatsAppService) FetchHealthStatus(_ context.Context, _ *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
return f.healthPayload, f.healthErr
}
func (f *fakeInboxWhatsAppService) SetupWebhook(_ context.Context, _ *channelmodel.ChannelWhatsApp, webhookURL string) error {
f.webhookURL = webhookURL
return f.webhookErr
}
func (f *fakeInboxWhatsAppService) SetupWebhookFields(_ context.Context, _ *channelmodel.ChannelWhatsApp, webhookURL string, fields []string) error {
f.webhookURL = webhookURL
f.webhookFields = fields
return f.webhookErr
}
func (f *fakeInboxWhatsAppService) UpdateCallingStatus(_ context.Context, _ *channelmodel.ChannelWhatsApp, status string) error {
f.callingStatus = status
return f.callingErr
}
// createInboxTestPrereqs creates prerequisite Account and Inbox for service tests.
func createInboxTestPrereqs(t *testing.T, db *gorm.DB, channelType string) (*model.Account, *model.Inbox) {
t.Helper()
account := &model.Account{Name: "InboxSvcTestOrg", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{
AccountID: account.ID,
Name: fmt.Sprintf("SvcTest%s", channelType),
ChannelType: channelType,
ChannelID: 1,
}
require.NoError(t, db.Create(inbox).Error)
return account, inbox
}
func ptrUint(v uint) *uint { return &v }
func TestInboxService_ListByAccountAndUser_OnlyAssignedInboxes(t *testing.T) {
svc, db := setupInboxServiceTest(t)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.InboxMember{}))
account, assigned := createInboxTestPrereqs(t, db, "web_widget")
hidden := &model.Inbox{AccountID: account.ID, Name: "Hidden", ChannelType: "api", ChannelID: 2}
require.NoError(t, db.Create(hidden).Error)
user := &model.User{Name: "Scoped Agent", Email: "service-scoped-agent@example.com"}
require.NoError(t, db.Create(user).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: assigned.ID, UserID: user.ID}).Error)
inboxes, total, err := svc.ListByAccountAndUser(context.Background(), account.ID, user.ID, 0, 25)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, inboxes, 1)
assert.Equal(t, assigned.ID, inboxes[0].ID)
}
func createWhatsAppInboxTestPrereqs(t *testing.T, db *gorm.DB, provider string) (*model.Account, *model.Inbox, *channelmodel.ChannelWhatsApp) {
t.Helper()
account, inbox := createInboxTestPrereqs(t, db, "whatsapp")
channel := &channelmodel.ChannelWhatsApp{
AccountID: account.ID,
InboxID: inbox.ID,
PhoneNumber: "+1555010000",
PhoneNumberID: "phone-123",
BusinessAccountID: "waba-456",
AccessToken: "token-789",
Provider: provider,
}
require.NoError(t, db.Create(channel).Error)
return account, inbox, channel
}
// createTestAgentBot creates an AgentBot for the given account.
func createTestAgentBot(t *testing.T, db *gorm.DB, accountID uint, suffix string) *model.AgentBot {
t.Helper()
bot := &model.AgentBot{
AccountID: &accountID,
Name: fmt.Sprintf("SvcBot-%s", suffix),
BotType: "default",
Secret: fmt.Sprintf("secret-svc-%s", suffix),
AccessToken: fmt.Sprintf("token-svc-%s", suffix),
}
require.NoError(t, db.Create(bot).Error)
return bot
}
func TestInboxService_Create_AllowsUnlimitedInboxLimit(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "Unlimited Inboxes", Locale: "en", Active: true, InboxLimit: 0}
require.NoError(t, db.Create(account).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: account.ID, Name: "Existing", ChannelType: "api"}).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "New API",
ChannelType: "api",
})
require.NoError(t, err)
require.NotZero(t, inbox.ID)
}
func TestInboxService_CreateRejectsAtAccountInboxLimit(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "Limited Inboxes", Locale: "en", Active: true, InboxLimit: 1}
require.NoError(t, db.Create(account).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: account.ID, Name: "Existing", ChannelType: "api"}).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "Blocked API",
ChannelType: "api",
})
require.ErrorIs(t, err, ErrInboxLimitExceeded)
require.Nil(t, inbox)
var count int64
require.NoError(t, db.Model(&model.Inbox{}).Where("account_id = ?", account.ID).Count(&count).Error)
assert.Equal(t, int64(1), count)
}
func TestInboxService_CreateAllowsBelowAccountInboxLimit(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "Below Limit", Locale: "en", Active: true, InboxLimit: 2}
require.NoError(t, db.Create(account).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: account.ID, Name: "Existing", ChannelType: "api"}).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "Allowed API",
ChannelType: "api",
})
require.NoError(t, err)
require.NotZero(t, inbox.ID)
var count int64
require.NoError(t, db.Model(&model.Inbox{}).Where("account_id = ?", account.ID).Count(&count).Error)
assert.Equal(t, int64(2), count)
}
func TestInboxService_CreateAPIInboxPersistsChannelAPI(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "API Channel Account", Locale: "en", Active: true, InboxLimit: 0}
require.NoError(t, db.Create(account).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "API Persist",
ChannelType: "api",
Channel: map[string]any{
"identifier": "api-persist-inbox",
"hmac_token": "api-persist-secret",
"hmac_mandatory": true,
"webhook_url": "https://example.test/hook",
"additional_attributes": map[string]any{
"agent_reply_time_window": 30,
},
},
})
require.NoError(t, err)
var channelAPI channelmodel.ChannelAPI
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error)
assert.Equal(t, inbox.Secret, channelAPI.Secret)
assert.Equal(t, "api-persist-inbox", channelAPI.Identifier)
assert.Equal(t, "api-persist-secret", channelAPI.HMACToken)
assert.True(t, channelAPI.HMACMandatory)
assert.Equal(t, "https://example.test/hook", channelAPI.WebhookURL)
assert.Equal(t, channelAPI.ID, inbox.ChannelID)
var attrs map[string]any
require.NoError(t, json.Unmarshal(channelAPI.AdditionalAttributes, &attrs))
assert.Equal(t, float64(30), attrs["agent_reply_time_window"])
}
func TestInboxService_UpdateAPIInboxPersistsChannelAPI(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "API Channel Update", Locale: "en", Active: true, InboxLimit: 0}
require.NoError(t, db.Create(account).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "API Update",
ChannelType: "api",
Channel: map[string]any{
"identifier": "api-update-inbox",
"hmac_token": "api-update-secret",
"hmac_mandatory": false,
},
})
require.NoError(t, err)
updated, err := svc.Update(context.Background(), account.ID, inbox.ID, UpdateInboxRequest{Channel: map[string]any{
"hmac_mandatory": true,
"webhook_url": "https://example.test/updated-hook",
"additional_attributes": map[string]any{
"agent_reply_time_window": 45,
},
}})
require.NoError(t, err)
var channelAPI channelmodel.ChannelAPI
require.NoError(t, db.Where("inbox_id = ?", updated.ID).First(&channelAPI).Error)
assert.Equal(t, updated.Secret, channelAPI.Secret)
assert.Equal(t, "api-update-inbox", channelAPI.Identifier)
assert.Equal(t, "api-update-secret", channelAPI.HMACToken)
assert.True(t, channelAPI.HMACMandatory)
assert.Equal(t, "https://example.test/updated-hook", channelAPI.WebhookURL)
var attrs map[string]any
require.NoError(t, json.Unmarshal(channelAPI.AdditionalAttributes, &attrs))
assert.Equal(t, float64(45), attrs["agent_reply_time_window"])
}
func TestInboxService_APIInboxRejectsInvalidAgentReplyTimeWindow(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "API Window Validation", Locale: "en", Active: true, InboxLimit: 0}
require.NoError(t, db.Create(account).Error)
created, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "Invalid Window",
ChannelType: "api",
Channel: map[string]any{
"additional_attributes": map[string]any{"agent_reply_time_window": 0},
},
})
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "agent_reply_time_window must be greater than 0")
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "Valid Window",
ChannelType: "api",
Channel: map[string]any{
"additional_attributes": map[string]any{"agent_reply_time_window": 12},
},
})
require.NoError(t, err)
updated, err := svc.Update(context.Background(), account.ID, inbox.ID, UpdateInboxRequest{Channel: map[string]any{
"additional_attributes": map[string]any{"agent_reply_time_window": "0"},
}})
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "agent_reply_time_window must be greater than 0")
}
func TestInboxService_CreateShangwutongPersistsSecretsOutsideInboxConfig(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "SWT Account", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "商务通站点", ChannelType: "shangwutong",
Channel: map[string]any{
"session_id": "BYT99917999", "username": "agent", "password": "plain-secret",
"desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1",
},
})
require.NoError(t, err)
assert.Equal(t, "{}", inbox.ChannelConfig)
assert.Equal(t, "shangwutong", inbox.ChannelType)
var channelAPI channelmodel.ChannelAPI
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error)
assert.True(t, channelAPI.HMACMandatory)
assert.NotEmpty(t, channelAPI.HMACToken)
assert.NotEmpty(t, channelAPI.Secret)
var config model.ChannelShangwutongConfig
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&config).Error)
assert.Equal(t, "plain-secret", config.Password)
assert.Equal(t, int64(1), config.ConfigVersion)
}
func TestInboxService_CreateShangwutongEnsuresContactAttributeDefinitions(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "SWT attributes", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
require.NoError(t, db.Create(&model.CustomAttributeDefinition{
AccountID: account.ID, AttributeName: "swt_ip", AttributeDisplayName: "保留现有名称",
AttributeType: "number", AttributeModel: "contact_attribute", Description: "保留现有说明",
}).Error)
create := func(name, sessionID, username string) {
t.Helper()
_, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: name, ChannelType: "shangwutong", Channel: map[string]any{
"session_id": sessionID, "username": username, "password": "secret",
"desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1",
},
})
require.NoError(t, err)
}
create("商务通一", "BYT99917999", "agent1")
create("商务通二", "BYT99917998", "agent2")
var definitions []model.CustomAttributeDefinition
require.NoError(t, db.Where("account_id = ? AND attribute_model = ? AND deleted_at IS NULL", account.ID, "contact_attribute").Order("attribute_name").Find(&definitions).Error)
require.Len(t, definitions, 16)
want := []string{
"swt_browser", "swt_browser_version", "swt_device", "swt_environment_version",
"swt_ip", "swt_ip_location", "swt_language", "swt_os", "swt_profile_channel",
"swt_query_title", "swt_query_word", "swt_resolution", "swt_site_id",
"swt_timezone", "swt_traffic_source", "swt_user_agent",
}
for index, definition := range definitions {
require.Equal(t, want[index], definition.AttributeName)
}
require.Equal(t, "保留现有名称", definitions[4].AttributeDisplayName)
require.Equal(t, "number", definitions[4].AttributeType)
require.Equal(t, "保留现有说明", definitions[4].Description)
var legacySourceDefinitions int64
require.NoError(t, db.Model(&model.CustomAttributeDefinition{}).
Where("account_id = ? AND attribute_name LIKE ?", account.ID, "swt_source_%").
Count(&legacySourceDefinitions).Error)
require.Zero(t, legacySourceDefinitions)
}
func TestInboxService_CreateShangwutongRollsBackWhenAttributeInitializationFails(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "SWT attribute rollback", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
require.NoError(t, db.Exec(`
CREATE TRIGGER fail_swt_attribute_definition
BEFORE INSERT ON custom_attribute_definitions
WHEN NEW.attribute_name = 'swt_ip_location'
BEGIN
SELECT RAISE(FAIL, 'forced attribute initialization failure');
END;
`).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "商务通回滚", ChannelType: "shangwutong", Channel: map[string]any{
"session_id": "BYT99917999", "username": "agent", "password": "secret",
"desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1",
},
})
require.ErrorContains(t, err, "forced attribute initialization failure")
require.Nil(t, inbox)
for _, target := range []any{
&model.Inbox{}, &channelmodel.ChannelAPI{}, &model.ChannelShangwutongConfig{}, &model.CustomAttributeDefinition{},
} {
var count int64
require.NoError(t, db.Model(target).Count(&count).Error)
require.Zero(t, count)
}
}
func TestInboxService_ShangwutongLifecycleJobsAreTransactionalAndKeepRotationDeleteSnapshots(t *testing.T) {
svc, db := setupInboxServiceTest(t)
wp := worker.NewWorkerPool(db)
svc.SetWorkerPool(wp)
account := &model.Account{Name: "Account", Active: true}
require.NoError(t, db.Create(account).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "商务通", ChannelType: "shangwutong", Channel: map[string]any{
"session_id": "LZA69557093", "username": "operator", "password": "secret-password",
"desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1",
},
})
require.NoError(t, err)
var channelAPI channelmodel.ChannelAPI
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&channelAPI).Error)
oldSecret := channelAPI.Secret
_, err = svc.ResetSecret(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
require.NoError(t, svc.DeleteByAccount(context.Background(), account.ID, inbox.ID))
var jobs []model.BackgroundJob
require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Order("id ASC").Find(&jobs).Error)
require.Len(t, jobs, 3)
var created, rotated, deleted shangwutongWebhookDeliveryJob
require.NoError(t, json.Unmarshal(jobs[0].Payload, &created))
require.NoError(t, json.Unmarshal(jobs[1].Payload, &rotated))
require.NoError(t, json.Unmarshal(jobs[2].Payload, &deleted))
require.Equal(t, "inbox_created", created.Event)
require.Empty(t, created.SigningSecret)
require.Equal(t, "inbox_updated", rotated.Event)
require.Equal(t, oldSecret, rotated.SigningSecret)
require.Equal(t, "inbox_deleted", deleted.Event)
require.True(t, deleted.Tombstone)
require.Equal(t, "http://connector:9100/webhooks/gochat/v1", deleted.WebhookURL)
require.NotEmpty(t, deleted.SigningSecret)
var activeConfigCount int64
require.NoError(t, db.Model(&model.ChannelShangwutongConfig{}).Where("inbox_id = ?", inbox.ID).Count(&activeConfigCount).Error)
require.Zero(t, activeConfigCount)
replacement, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "商务通替换", ChannelType: "shangwutong", Channel: map[string]any{
"session_id": "LZA69557093", "username": "operator", "password": "replacement-password",
"desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1",
},
})
require.NoError(t, err)
require.NotEqual(t, inbox.ID, replacement.ID)
for _, job := range jobs {
require.NotContains(t, string(job.Payload), "secret-password")
require.NotContains(t, string(job.Payload), "operator")
require.NotContains(t, string(job.Payload), "LZA69557093")
}
}
func TestInboxService_UpdateShangwutongVersionsConnectorFieldsAndKeepsOmittedPassword(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "SWT Update", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "商务通站点", ChannelType: "shangwutong",
Channel: map[string]any{
"session_id": "BYT99917999", "username": "agent", "password": "plain-secret",
"desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1",
},
})
require.NoError(t, err)
updated, err := svc.Update(context.Background(), account.ID, inbox.ID, UpdateInboxRequest{
Name: "只改名称", Channel: map[string]any{"desired_presence": "busy"},
})
require.NoError(t, err)
assert.Equal(t, "只改名称", updated.Name)
var config model.ChannelShangwutongConfig
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&config).Error)
assert.Equal(t, "plain-secret", config.Password)
assert.Equal(t, "busy", config.DesiredPresence)
assert.Equal(t, int64(2), config.ConfigVersion)
_, err = svc.Update(context.Background(), account.ID, inbox.ID, UpdateInboxRequest{
Channel: map[string]any{"username": "other"},
})
require.ErrorContains(t, err, "cannot be changed in place")
}
func TestInboxService_SharedShangwutongConnectorURLIsEnforced(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "SWT URLs", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
base := CreateInboxRequest{Name: "商务通一", ChannelType: "shangwutong", Channel: map[string]any{
"session_id": "BYT99917999", "username": "agent1", "password": "secret",
"desired_presence": "online", "webhook_url": "http://connector:9100/webhooks/gochat/v1",
}}
_, err := svc.Create(context.Background(), account.ID, base)
require.NoError(t, err)
base.Name = "商务通二"
base.Channel["session_id"] = "BYT99917998"
base.Channel["username"] = "agent2"
base.Channel["webhook_url"] = "http://other:9100/webhooks/gochat/v1"
_, err = svc.Create(context.Background(), account.ID, base)
require.ErrorContains(t, err, "same webhook_url")
}
func TestInboxService_DeletedShangwutongIdentityCanBeRecreated(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "SWT identity reuse", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
request := CreateInboxRequest{Name: "商务通旧收件箱", Channel: map[string]any{
"type": "shangwutong", "session_id": "BYT99917999", "username": "agent", "password": "secret",
"desired_presence": "offline", "webhook_url": "http://connector:9100/webhooks/gochat/v1",
}}
first, err := svc.Create(context.Background(), account.ID, request)
require.NoError(t, err)
require.NoError(t, svc.DeleteByAccount(context.Background(), account.ID, first.ID))
request.Name = "商务通新收件箱"
second, err := svc.Create(context.Background(), account.ID, request)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
}
func TestInboxService_ResetSecretRegeneratesAPIWebhookSecret(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account := &model.Account{Name: "API Secret Reset", Locale: "en", Active: true, InboxLimit: 0}
require.NoError(t, db.Create(account).Error)
inbox, err := svc.Create(context.Background(), account.ID, CreateInboxRequest{
Name: "API Secret Reset",
ChannelType: "api",
Channel: map[string]any{
"hmac_token": "public-hmac-token",
"webhook_url": "https://example.test/reset-hook",
},
})
require.NoError(t, err)
var before channelmodel.ChannelAPI
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&before).Error)
require.NotEmpty(t, before.Secret)
reset, err := svc.ResetSecret(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
var after channelmodel.ChannelAPI
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&after).Error)
assert.NotEqual(t, before.Secret, after.Secret)
assert.Equal(t, after.Secret, reset.Secret)
assert.Equal(t, before.HMACToken, after.HMACToken)
assert.Equal(t, "public-hmac-token", after.HMACToken)
}
// ========================================
// SetAgentBot service tests
// ========================================
func TestInboxService_SetAgentBot_AssignBot(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
bot := createTestAgentBot(t, db, account.ID, "assign")
binding, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: ptrUint(bot.ID),
})
require.NoError(t, err)
assert.NotNil(t, binding)
assert.Equal(t, bot.ID, binding.AgentBotID)
assert.Equal(t, inbox.ID, binding.InboxID)
assert.Equal(t, model.AgentBotInboxActive, binding.Status)
}
func TestInboxService_SetAgentBot_RemoveBot(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
bot := createTestAgentBot(t, db, account.ID, "remove")
// First assign a bot
binding, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: ptrUint(bot.ID),
})
require.NoError(t, err)
assert.NotNil(t, binding)
// Now remove it (missing/null agent_bot)
result, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{})
require.NoError(t, err)
assert.Nil(t, result, "removing bot should return nil binding")
bindings, err := repository.NewAgentBotInboxRepo(db).FindByInboxID(context.Background(), inbox.ID)
require.NoError(t, err)
assert.Empty(t, bindings, "Chatwoot destroys the agent_bot_inbox row on disconnect")
}
func TestInboxService_SetAgentBot_InboxNotFound(t *testing.T) {
svc, _ := setupInboxServiceTest(t)
binding, err := svc.SetAgentBot(context.Background(), 9999, 9999, SetAgentBotRequest{
AgentBotID: ptrUint(1),
})
assert.Error(t, err)
assert.Nil(t, binding)
assert.Contains(t, err.Error(), "inbox not found")
}
func TestInboxService_SetAgentBot_BotNotFound(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
binding, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: ptrUint(9999),
})
assert.Error(t, err)
assert.Nil(t, binding)
assert.Contains(t, err.Error(), "agent bot not found")
}
func TestInboxService_SetAgentBot_ReassignBot(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
bot1 := createTestAgentBot(t, db, account.ID, "first")
bot2 := createTestAgentBot(t, db, account.ID, "second")
// Assign first bot
binding1, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: ptrUint(bot1.ID),
})
require.NoError(t, err)
assert.Equal(t, bot1.ID, binding1.AgentBotID)
// Reassign to second bot — Chatwoot updates the inbox's single has_one binding.
binding2, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: ptrUint(bot2.ID),
})
require.NoError(t, err)
assert.Equal(t, bot2.ID, binding2.AgentBotID)
assert.Equal(t, model.AgentBotInboxActive, binding2.Status)
assert.Equal(t, binding1.ID, binding2.ID)
}
func TestInboxService_SetAgentBot_RecreatesBindingAfterDisconnect(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
bot := createTestAgentBot(t, db, account.ID, "recreate")
// Assign bot
_, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: ptrUint(bot.ID),
})
require.NoError(t, err)
// Remove bot (destroy binding)
_, err = svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{})
require.NoError(t, err)
// Re-assign same bot — should create a fresh binding.
binding2, err := svc.SetAgentBot(context.Background(), account.ID, inbox.ID, SetAgentBotRequest{
AgentBotID: ptrUint(bot.ID),
})
require.NoError(t, err)
assert.NotNil(t, binding2)
assert.Equal(t, bot.ID, binding2.AgentBotID)
assert.Equal(t, model.AgentBotInboxActive, binding2.Status)
}
// ========================================
// Health service tests
// ========================================
func TestInboxService_Health_NonWhatsAppInboxRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
assert.Nil(t, result)
}
func TestInboxService_Health_ShangwutongReturnsOnlyRuntimeState(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "shangwutong")
rejected := "auth_failed"
require.NoError(t, db.Create(&model.ChannelShangwutongConfig{
InboxID: inbox.ID, SessionID: "LZA69557093", Username: "operator", Password: "plain-secret",
DesiredPresence: "busy", ConfigVersion: 7, ActualPresence: "online",
ConnectionStatus: "connected", CredentialStatus: "rejected", LastErrorCode: &rejected,
}).Error)
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.Equal(t, int64(7), result["config_version"])
assert.Equal(t, "busy", result["desired_presence"])
assert.Equal(t, "connected", result["connection_status"])
assert.Equal(t, "rejected", result["credential_status"])
assert.NotContains(t, result, "password")
assert.NotContains(t, result, "session_id")
assert.NotContains(t, result, "username")
}
func TestInboxService_Health_WhatsAppCloudReturnsPayload(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
svc.whatsappService = &fakeInboxWhatsAppService{healthPayload: map[string]interface{}{
"id": "phone-123",
"quality_rating": "GREEN",
"expected_webhook_url": "https://app.test/webhooks/whatsapp/+1555010000",
"business_id": "waba-456",
}}
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.Equal(t, "phone-123", result["id"])
assert.Equal(t, "GREEN", result["quality_rating"])
assert.NotContains(t, result, "healthy")
assert.NotContains(t, result, "status")
}
func TestInboxService_Health_NonCloudWhatsAppRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "360dialog")
svc.whatsappService = &fakeInboxWhatsAppService{}
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
assert.Nil(t, result)
}
func TestInboxService_Health_InboxNotFound(t *testing.T) {
svc, _ := setupInboxServiceTest(t)
result, err := svc.Health(context.Background(), 9999, 9999)
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "inbox not found")
}
// ========================================
// SyncTemplates service tests
// ========================================
func TestInboxService_SyncTemplates_NonWhatsAppInbox(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
err := svc.SyncTemplates(context.Background(), account.ID, inbox.ID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "Template sync is only available for WhatsApp channels")
}
func TestInboxService_SyncTemplates_InboxNotFound(t *testing.T) {
svc, _ := setupInboxServiceTest(t)
err := svc.SyncTemplates(context.Background(), 9999, 9999)
assert.Error(t, err)
assert.Contains(t, err.Error(), "inbox not found")
}
func TestInboxService_SyncTemplates_WhatsAppQueuesWorkerJob(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, channel := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
wp := worker.NewWorkerPool(db)
svc.SetWorkerPool(wp)
svc.whatsappService = &fakeInboxWhatsAppService{}
err := svc.SyncTemplates(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
var job model.BackgroundJob
require.NoError(t, db.Where("job_type = ? AND queue = ?", TaskTypeInboxSyncTemplates, "low").First(&job).Error)
var payload inboxTemplateSyncJob
require.NoError(t, json.Unmarshal(job.Payload, &payload))
assert.Equal(t, account.ID, payload.AccountID)
assert.Equal(t, inbox.ID, payload.InboxID)
assert.Equal(t, channel.ID, payload.ChannelID)
}
func TestInboxService_SyncTemplatesWorkerFetchesTemplates(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
fake := &fakeInboxWhatsAppService{templates: []interface{}{map[string]interface{}{"name": "hello_world"}}}
svc.whatsappService = fake
wp := worker.NewWorkerPool(db)
svc.SetWorkerPool(wp)
RegisterInboxTemplateSyncJobs(wp, svc)
require.NoError(t, svc.SyncTemplates(context.Background(), account.ID, inbox.ID))
processed, err := wp.ProcessOne(context.Background())
require.NoError(t, err)
assert.True(t, processed)
assert.Equal(t, 1, fake.fetchCalls)
}
// ========================================
// RegisterWebhook service tests
// ========================================
func TestInboxService_RegisterWebhook_NonWhatsAppInboxRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{
URL: "https://example.com/webhook",
})
require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
}
func TestInboxService_RegisterWebhook_InboxNotFound(t *testing.T) {
svc, _ := setupInboxServiceTest(t)
err := svc.RegisterWebhook(context.Background(), 9999, 9999, RegisterWebhookRequest{
URL: "https://example.com/webhook",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "inbox not found")
}
func TestInboxService_RegisterWebhook_WhatsAppCloudUsesExpectedCallbackURL(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
fake := &fakeInboxWhatsAppService{}
svc.whatsappService = fake
t.Setenv("FRONTEND_URL", "https://app.example.com/")
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{})
require.NoError(t, err)
assert.Equal(t, "https://app.example.com/webhooks/whatsapp/+1555010000", fake.webhookURL)
}
func TestInboxService_RegisterWebhook_NonCloudWhatsAppRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "360dialog")
svc.whatsappService = &fakeInboxWhatsAppService{}
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{})
require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
}
// ========================================
// WhatsApp calling service tests
// ========================================
func TestInboxService_EnableWhatsAppCalling_SetsProviderConfigAndWebhook(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, channel := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
account.FeatureFlags = `{"channel_voice":true}`
require.NoError(t, db.Save(account).Error)
fake := &fakeInboxWhatsAppService{}
svc.whatsappService = fake
t.Setenv("FRONTEND_URL", "https://app.example.test")
err := svc.EnableWhatsAppCalling(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.Equal(t, "ENABLED", fake.callingStatus)
assert.Equal(t, "https://app.example.test/webhooks/whatsapp/+1555010000", fake.webhookURL)
assert.Empty(t, fake.webhookFields)
var updated channelmodel.ChannelWhatsApp
require.NoError(t, db.First(&updated, channel.ID).Error)
providerConfig := parseJSONMap(updated.ProviderConfig)
assert.Equal(t, true, providerConfig["calling_enabled"])
var updatedInbox model.Inbox
require.NoError(t, db.First(&updatedInbox, inbox.ID).Error)
channelConfig := parseJSONMap(updatedInbox.ChannelConfig)
assert.Equal(t, true, channelConfig["voice_enabled"])
}
func TestInboxService_EnableWhatsAppCalling_RequiresCloudAndFeature(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "360dialog")
account.FeatureFlags = `{"channel_voice":true}`
require.NoError(t, db.Save(account).Error)
svc.whatsappService = &fakeInboxWhatsAppService{}
err := svc.EnableWhatsAppCalling(context.Background(), account.ID, inbox.ID)
require.ErrorIs(t, err, ErrInboxWhatsAppCallingUnsupported)
account, inbox, _ = createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
err = svc.EnableWhatsAppCalling(context.Background(), account.ID, inbox.ID)
require.ErrorIs(t, err, ErrInboxWhatsAppCallingFeatureRequired)
}
func TestInboxService_DisableWhatsAppCalling_PersistsFalseAndIgnoresWebhookFailure(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, channel := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
channel.ProviderConfig = `{"calling_enabled":true,"phone_number_id":"phone-1","business_account_id":"waba-1"}`
require.NoError(t, db.Save(channel).Error)
fake := &fakeInboxWhatsAppService{webhookErr: fmt.Errorf("meta unavailable")}
svc.whatsappService = fake
t.Setenv("FRONTEND_URL", "https://app.example.test")
err := svc.DisableWhatsAppCalling(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.Equal(t, []string{"messages", "smb_message_echoes"}, fake.webhookFields)
var updated channelmodel.ChannelWhatsApp
require.NoError(t, db.First(&updated, channel.ID).Error)
providerConfig := parseJSONMap(updated.ProviderConfig)
assert.Equal(t, false, providerConfig["calling_enabled"])
}
func TestInboxService_SetInboundCalls_PersistsVoiceInboxSetting(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox, channel := createWhatsAppInboxTestPrereqs(t, db, "whatsapp_cloud")
inbox.ChannelConfig = `{"voice_enabled":true,"inbound_calls_enabled":true}`
require.NoError(t, db.Save(inbox).Error)
require.NoError(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, false))
var updated model.Inbox
require.NoError(t, db.First(&updated, inbox.ID).Error)
assert.Equal(t, false, parseJSONMap(updated.ChannelConfig)["inbound_calls_enabled"])
var updatedChannel channelmodel.ChannelWhatsApp
require.NoError(t, db.First(&updatedChannel, channel.ID).Error)
assert.Equal(t, false, parseJSONMap(updatedChannel.ProviderConfig)["inbound_calls_enabled"])
require.NoError(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, true))
require.NoError(t, db.First(&updated, inbox.ID).Error)
assert.Equal(t, true, parseJSONMap(updated.ChannelConfig)["inbound_calls_enabled"])
}
func TestInboxService_SetInboundCalls_RejectsUnsupportedInbox(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
require.ErrorIs(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, true), ErrInboxInboundCallsUnsupported)
inbox.ChannelType = "whatsapp"
inbox.ChannelConfig = `{}`
require.NoError(t, db.Save(inbox).Error)
require.ErrorIs(t, svc.SetInboundCalls(context.Background(), account.ID, inbox.ID, true), ErrInboxInboundCallsUnsupported)
}