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"]) }