Files
gochat/internal/service/inbox_service_test.go
T

528 lines
19 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.AgentBotInbox{},
&model.WebhookSubscription{},
&model.BackgroundJob{},
&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 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)
}
// ========================================
// 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_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"])
}