feat(inboxes): align whatsapp health endpoints

This commit is contained in:
2026-06-05 22:25:04 +08:00
parent 3fc275b639
commit af9e1eb711
6 changed files with 276 additions and 272 deletions
+72 -3
View File
@@ -19,6 +19,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"time"
channelmodel "github.com/gochat/gochat/internal/model/channel"
@@ -43,10 +44,15 @@ func NewWhatsAppService(repository *Repository) *WhatsAppService {
client.SetRetryWaitTime(1 * time.Second)
client.SetRetryMaxWaitTime(5 * time.Second)
apiVersion := os.Getenv("WHATSAPP_API_VERSION")
if apiVersion == "" {
apiVersion = "v22.0"
}
return &WhatsAppService{
client: client,
repository: repository,
graphAPIBase: "https://graph.facebook.com/v18.0",
graphAPIBase: "https://graph.facebook.com/" + apiVersion,
dialogAPIBase: "https://waba.360dialog.io",
}
}
@@ -55,7 +61,8 @@ func NewWhatsAppService(repository *Repository) *WhatsAppService {
// CreateChannel creates a new WhatsApp channel.
// Reference: Chatwoot WhatsApp channel creation flow:
// validate phone_number_id → create ChannelWhatsApp → setup webhook → create Inbox
//
// validate phone_number_id → create ChannelWhatsApp → setup webhook → create Inbox
//
// Flow:
// 1. Validate provider (whatsapp_cloud or 360dialog)
@@ -325,6 +332,68 @@ func (s *WhatsAppService) FetchMessageTemplates(ctx context.Context, channel *ch
return result.Data, nil
}
// FetchHealthStatus retrieves WhatsApp Cloud phone-number health data and formats
// it to the payload consumed by Chatwoot's inbox settings screen.
// Reference: reference/chatwoot/app/services/whatsapp/health_service.rb
func (s *WhatsAppService) FetchHealthStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
if channel == nil {
return nil, fmt.Errorf("Channel is required")
}
if channel.AccessToken == "" {
return nil, fmt.Errorf("API key is missing")
}
if channel.PhoneNumberID == "" {
return nil, fmt.Errorf("Phone number ID is missing")
}
var result map[string]interface{}
url := fmt.Sprintf("%s/%s", s.graphAPIBase, channel.PhoneNumberID)
resp, err := s.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"fields": whatsappHealthFields(),
"access_token": channel.AccessToken,
}).
SetResult(&result).
Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch WhatsApp health status: %w", err)
}
if resp.IsError() {
return nil, fmt.Errorf("WhatsApp API request failed: %d - %s", resp.StatusCode(), resp.String())
}
return formatWhatsAppHealthResponse(result, channel), nil
}
func whatsappHealthFields() string {
return "id,quality_rating,messaging_limit_tier,code_verification_status,account_mode,display_phone_number,name_status,verified_name,webhook_configuration,throughput,last_onboarded_time,platform_type,certificate"
}
func formatWhatsAppHealthResponse(response map[string]interface{}, channel *channelmodel.ChannelWhatsApp) map[string]interface{} {
return map[string]interface{}{
"id": response["id"],
"display_phone_number": response["display_phone_number"],
"verified_name": response["verified_name"],
"name_status": response["name_status"],
"quality_rating": response["quality_rating"],
"messaging_limit_tier": response["messaging_limit_tier"],
"account_mode": response["account_mode"],
"code_verification_status": response["code_verification_status"],
"webhook_configuration": response["webhook_configuration"],
"expected_webhook_url": buildExpectedWhatsAppWebhookURL(channel.PhoneNumber),
"throughput": response["throughput"],
"last_onboarded_time": response["last_onboarded_time"],
"platform_type": response["platform_type"],
"certificate": response["certificate"],
"business_id": channel.BusinessAccountID,
}
}
func buildExpectedWhatsAppWebhookURL(phoneNumber string) string {
return fmt.Sprintf("%s/webhooks/whatsapp/%s", os.Getenv("FRONTEND_URL"), phoneNumber)
}
// === Webhook Management ===
// SetupWebhook registers webhook subscription with WhatsApp provider.
@@ -484,4 +553,4 @@ func (s *WhatsAppService) deleteWebhook(ctx context.Context, channel *channelmod
// GetChannelByInboxID retrieves the WhatsApp channel associated with an inbox.
func (s *WhatsAppService) GetChannelByInboxID(ctx context.Context, inboxID uint) (*channelmodel.ChannelWhatsApp, error) {
return s.repository.GetByInboxID(ctx, inboxID)
}
}
+52
View File
@@ -0,0 +1,52 @@
package whatsapp
import (
"bytes"
"context"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
channelmodel "github.com/gochat/gochat/internal/model/channel"
)
func TestWhatsAppService_FetchHealthStatusFormatsChatwootPayload(t *testing.T) {
t.Setenv("FRONTEND_URL", "https://app.example.com")
svc := NewWhatsAppService(nil)
svc.graphAPIBase = "https://graph.example.test/v22.0"
svc.client.SetTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) {
require.Equal(t, "/v22.0/phone-123", r.URL.Path)
assert.Contains(t, r.URL.Query().Get("fields"), "quality_rating")
assert.Equal(t, "token-abc", r.URL.Query().Get("access_token"))
body := `{"id":"phone-123","display_phone_number":"+1 555 0100","verified_name":"Support","name_status":"APPROVED","quality_rating":"GREEN","messaging_limit_tier":"TIER_1K","account_mode":"LIVE","code_verification_status":"VERIFIED","webhook_configuration":{"application":"app-1"},"throughput":{"level":"STANDARD"},"last_onboarded_time":"2026-06-05T00:00:00Z","platform_type":"CLOUD_API","certificate":"cert-data"}`
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(body)),
}, nil
}))
channel := &channelmodel.ChannelWhatsApp{
PhoneNumber: "+1555010000",
PhoneNumberID: "phone-123",
BusinessAccountID: "waba-456",
AccessToken: "token-abc",
}
payload, err := svc.FetchHealthStatus(context.Background(), channel)
require.NoError(t, err)
assert.Equal(t, "phone-123", payload["id"])
assert.Equal(t, "GREEN", payload["quality_rating"])
assert.Equal(t, "https://app.example.com/webhooks/whatsapp/+1555010000", payload["expected_webhook_url"])
assert.Equal(t, "waba-456", payload["business_id"])
assert.NotContains(t, payload, "healthy")
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+15 -4
View File
@@ -2,6 +2,7 @@ package v1
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
@@ -623,6 +624,10 @@ func (h *InboxHandler) Health(c *gin.Context) {
result, svcErr := h.svc.Health(c.Request.Context(), accountID, inboxID)
if svcErr != nil {
if errors.Is(svcErr, service.ErrInboxHealthWhatsAppCloudOnly) {
c.JSON(http.StatusBadRequest, gin.H{"error": service.InboxHealthWhatsAppCloudOnlyMessage})
return
}
handleServiceError(c, svcErr)
return
}
@@ -677,9 +682,11 @@ func (h *InboxHandler) RegisterWebhook(c *gin.Context) {
}
var req service.RegisterWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
return
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
return
}
}
if !h.svc.Ready() {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to register webhook")
@@ -688,11 +695,15 @@ func (h *InboxHandler) RegisterWebhook(c *gin.Context) {
svcErr := h.svc.RegisterWebhook(c.Request.Context(), accountID, inboxID, req)
if svcErr != nil {
if errors.Is(svcErr, service.ErrInboxHealthWhatsAppCloudOnly) {
c.JSON(http.StatusBadRequest, gin.H{"error": service.InboxHealthWhatsAppCloudOnlyMessage})
return
}
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, gin.H{"message": "webhook registered successfully"})
c.JSON(http.StatusOK, gin.H{"message": "Webhook registered successfully"})
}
// GetAgentBot retrieves the currently active agent bot for an inbox.
+49 -130
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/gochat/gochat/internal/campaign"
@@ -19,8 +20,16 @@ import (
)
const InboxLimitExceededMessage = "Account limit exceeded. Upgrade to a higher plan"
const InboxHealthWhatsAppCloudOnlyMessage = "Health data only available for WhatsApp Cloud API channels"
var ErrInboxLimitExceeded = errors.New(InboxLimitExceededMessage)
var ErrInboxHealthWhatsAppCloudOnly = errors.New(InboxHealthWhatsAppCloudOnlyMessage)
type WhatsAppChannelService interface {
FetchMessageTemplates(ctx context.Context, channel *channelmodel.ChannelWhatsApp) ([]interface{}, error)
FetchHealthStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error)
SetupWebhook(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string) error
}
// InboxService implements business logic for Inbox operations.
// Reference: Chatwoot app/controllers/api/v1/inboxes_controller.rb
@@ -30,7 +39,7 @@ type InboxService struct {
agentBotRepo *repository.AgentBotRepo
campaignRepo *repository.CampaignRepo
webhookSubRepo *repository.WebhookSubscriptionRepo
whatsappService *whatsapp.WhatsAppService
whatsappService WhatsAppChannelService
whatsappRepo *whatsapp.Repository
}
@@ -41,7 +50,7 @@ func NewInboxService(
agentBotRepo *repository.AgentBotRepo,
campaignRepo *repository.CampaignRepo,
webhookSubRepo *repository.WebhookSubscriptionRepo,
whatsappService *whatsapp.WhatsAppService,
whatsappService WhatsAppChannelService,
whatsappRepo *whatsapp.Repository,
) *InboxService {
return &InboxService{
@@ -1595,99 +1604,25 @@ func (s *InboxService) GetAgentBot(ctx context.Context, accountID, inboxID uint)
return bot, nil
}
// InboxHealthResult represents the health check result for an inbox.
// Reference: Chatwoot InboxesController#health (GET member action)
type InboxHealthResult struct {
InboxID uint `json:"inbox_id"`
ChannelType string `json:"channel_type"`
Healthy bool `json:"healthy"`
Status string `json:"status"` // "connected", "disconnected", "misconfigured", "unknown"
Details string `json:"details,omitempty"`
}
// Health checks the health status of an inbox's channel connection.
// For WhatsApp inboxes, this checks the WhatsApp API connection (token validity, webhook setup).
// For API inboxes, this checks if the webhook_url is configured.
// For other channel types, returns basic connectivity status.
// Reference: Chatwoot InboxesController#health (uses WhatsappHealthManagement for WhatsApp)
func (s *InboxService) Health(ctx context.Context, accountID, inboxID uint) (*InboxHealthResult, error) {
// Health returns Chatwoot's WhatsApp Cloud health payload for an inbox.
// Reference: Api::V1::Accounts::Concerns::WhatsappHealthManagement#health
func (s *InboxService) Health(ctx context.Context, accountID, inboxID uint) (map[string]interface{}, error) {
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
if err != nil {
return nil, fmt.Errorf("inbox not found: %w", err)
}
result := &InboxHealthResult{
InboxID: inbox.ID,
ChannelType: inbox.ChannelType,
if inbox.ChannelType != "whatsapp" {
return nil, ErrInboxHealthWhatsAppCloudOnly
}
switch inbox.ChannelType {
case "whatsapp":
// WhatsApp health check: verify the channel configuration is valid
// and the connection to the WhatsApp Business API is healthy
result.Status = "connected"
result.Healthy = true
// Check if the WhatsApp channel record exists and has valid credentials
waChannel, waErr := s.getWhatsAppChannel(ctx, inbox.ID)
if waErr != nil {
result.Status = "misconfigured"
result.Healthy = false
result.Details = fmt.Sprintf("WhatsApp channel config error: %v", waErr)
} else if waChannel.ReauthorizationRequired {
result.Status = "disconnected"
result.Healthy = false
result.Details = "WhatsApp token requires reauthorization"
} else if waChannel.PhoneNumberID == "" || waChannel.AccessToken == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "WhatsApp channel missing phone_number_id or access_token"
} else {
result.Details = "WhatsApp Business API connection is healthy"
}
case "api":
// API inbox health: check if webhook_url and secret are configured
if inbox.WebhookURL == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "API inbox missing webhook_url"
} else if inbox.Secret == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "API inbox missing secret for webhook verification"
} else {
result.Status = "connected"
result.Healthy = true
result.Details = "API inbox webhook configured"
}
case "telegram":
// Telegram health: check if bot token is configured in channel_config
config := parseChannelConfigMap(inbox.ChannelConfig)
if _, ok := config["bot_token"]; !ok || config["bot_token"] == "" {
result.Status = "misconfigured"
result.Healthy = false
result.Details = "Telegram inbox missing bot_token"
} else {
result.Status = "connected"
result.Healthy = true
result.Details = "Telegram bot configured"
}
case "web_widget":
// Web widget doesn't have external connectivity requirements
result.Status = "connected"
result.Healthy = true
result.Details = "Web widget is always healthy"
default:
result.Status = "unknown"
result.Healthy = true
result.Details = fmt.Sprintf("Health check not implemented for channel type: %s", inbox.ChannelType)
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
if err != nil {
return nil, err
}
return result, nil
if !waChannel.IsCloudAPI() {
return nil, ErrInboxHealthWhatsAppCloudOnly
}
return s.fetchWhatsAppHealthStatus(ctx, waChannel)
}
// SyncTemplates syncs message templates for an inbox's channel (currently WhatsApp only).
@@ -1725,13 +1660,12 @@ func (s *InboxService) SyncTemplates(ctx context.Context, accountID, inboxID uin
// RegisterWebhookRequest represents the request body for registering a webhook on an inbox.
// Reference: Chatwoot InboxesController#register_webhook (POST member action)
type RegisterWebhookRequest struct {
URL string `json:"url" validate:"required,url"`
URL string `json:"url"`
Events []string `json:"events,omitempty"` // e.g. ["message_created", "conversation_updated"]
}
// RegisterWebhook registers a webhook URL with the channel provider for an inbox.
// For WhatsApp inboxes, this registers the webhook with the WhatsApp Business API.
// For API inboxes, this creates a webhook subscription in the gochat system.
// Chatwoot exposes this member action only for WhatsApp Cloud inboxes.
// Reference: Chatwoot InboxesController#register_webhook
func (s *InboxService) RegisterWebhook(ctx context.Context, accountID, inboxID uint, req RegisterWebhookRequest) error {
inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID)
@@ -1739,46 +1673,24 @@ func (s *InboxService) RegisterWebhook(ctx context.Context, accountID, inboxID u
return fmt.Errorf("inbox not found: %w", err)
}
switch inbox.ChannelType {
case "whatsapp":
// Register webhook with WhatsApp Business API
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
if err != nil {
return fmt.Errorf("failed to get WhatsApp channel: %w", err)
}
if err := s.setupWhatsAppWebhook(ctx, waChannel, req.URL); err != nil {
return fmt.Errorf("failed to register webhook with WhatsApp: %w", err)
}
applogger.L().Infof("Registered webhook for WhatsApp inbox %d (account_id=%d)", inboxID, accountID)
case "api":
// For API inboxes, store the webhook URL on the inbox and create a system webhook subscription
if err := s.repo.UpdateFields(ctx, inbox.ID, map[string]interface{}{
"webhook_url": req.URL,
}); err != nil {
return fmt.Errorf("failed to update inbox webhook_url: %w", err)
}
// Also create a webhook subscription record if events are specified
if len(req.Events) > 0 {
eventsJSON, _ := json.Marshal(req.Events)
secret := generateInboxSecret()
sub := &model.WebhookSubscription{
AccountID: accountID,
URL: req.URL,
Events: json.RawMessage(eventsJSON),
Secret: secret,
Active: true,
}
if err := s.webhookSubRepo.Create(ctx, sub); err != nil {
applogger.L().Warnf("Failed to create webhook subscription for API inbox %d: %v", inboxID, err)
// Non-fatal: the webhook_url is saved on the inbox regardless
}
}
applogger.L().Infof("Registered webhook for API inbox %d (account_id=%d)", inboxID, accountID)
default:
return fmt.Errorf("register_webhook is only supported for WhatsApp and API inboxes")
if inbox.ChannelType != "whatsapp" {
return ErrInboxHealthWhatsAppCloudOnly
}
waChannel, err := s.getWhatsAppChannel(ctx, inbox.ID)
if err != nil {
return fmt.Errorf("failed to get WhatsApp channel: %w", err)
}
if !waChannel.IsCloudAPI() {
return ErrInboxHealthWhatsAppCloudOnly
}
webhookURL := req.URL
if webhookURL == "" {
webhookURL = fmt.Sprintf("%s/webhooks/whatsapp/%s", strings.TrimRight(os.Getenv("FRONTEND_URL"), "/"), waChannel.PhoneNumber)
}
if err := s.setupWhatsAppWebhook(ctx, waChannel, webhookURL); err != nil {
return fmt.Errorf("failed to register webhook with WhatsApp: %w", err)
}
applogger.L().Infof("Registered webhook for WhatsApp inbox %d (account_id=%d)", inboxID, accountID)
return nil
}
@@ -1840,6 +1752,13 @@ func (s *InboxService) fetchWhatsAppTemplates(ctx context.Context, waChannel *ch
return s.whatsappService.FetchMessageTemplates(ctx, waChannel)
}
func (s *InboxService) fetchWhatsAppHealthStatus(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
if s.whatsappService == nil {
return nil, fmt.Errorf("WhatsApp service not available")
}
return s.whatsappService.FetchHealthStatus(ctx, waChannel)
}
// setupWhatsAppWebhook registers a webhook URL with the WhatsApp Business API.
func (s *InboxService) setupWhatsAppWebhook(ctx context.Context, waChannel *channelmodel.ChannelWhatsApp, webhookURL string) error {
if s.whatsappService == nil {
+81 -130
View File
@@ -2,7 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
@@ -12,7 +12,9 @@ import (
"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"
)
@@ -35,6 +37,7 @@ func setupInboxServiceTest(t *testing.T) (*InboxService, *gorm.DB) {
&model.Inbox{},
&model.AgentBotInbox{},
&model.WebhookSubscription{},
&channelmodel.ChannelWhatsApp{},
), "failed to auto-migrate")
t.Cleanup(func() {
@@ -47,11 +50,31 @@ func setupInboxServiceTest(t *testing.T) (*InboxService, *gorm.DB) {
agentBotRepo := repository.NewAgentBotRepo(db)
webhookSubRepo := repository.NewWebhookSubscriptionRepo(db)
// WhatsApp repos/services are nil — we only test non-WhatsApp paths
svc := NewInboxService(repo, agentBotInboxRepo, agentBotRepo, nil, webhookSubRepo, nil, nil)
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
webhookURL string
webhookErr error
}
func (f *fakeInboxWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) {
return nil, errors.New("not used")
}
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
}
// 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()
@@ -70,6 +93,22 @@ func createInboxTestPrereqs(t *testing.T, db *gorm.DB, channelType string) (*mod
return account, inbox
}
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()
@@ -250,88 +289,41 @@ func TestInboxService_SetAgentBot_ReactivateExistingBinding(t *testing.T) {
// Health service tests
// ========================================
func TestInboxService_Health_APIInbox_Healthy(t *testing.T) {
func TestInboxService_Health_NonWhatsAppInboxRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
// Set webhook_url and secret on the inbox (API inbox health checks both)
require.NoError(t, db.Model(inbox).Update("webhook_url", "https://example.com/hook").Error)
require.NoError(t, db.Model(inbox).Update("secret", "my-secret-key").Error)
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, inbox.ID, result.InboxID)
assert.Equal(t, "api", result.ChannelType)
assert.True(t, result.Healthy)
assert.Equal(t, "connected", result.Status)
require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
assert.Nil(t, result)
}
func TestInboxService_Health_APIInbox_Misconfigured(t *testing.T) {
func TestInboxService_Health_WhatsAppCloudReturnsPayload(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
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",
}}
// Leave webhook_url empty — should be misconfigured
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.NotNil(t, result)
assert.False(t, result.Healthy)
assert.Equal(t, "misconfigured", result.Status)
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_APIInbox_MissingSecret(t *testing.T) {
func TestInboxService_Health_NonCloudWhatsAppRejected(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
// Set webhook_url but leave secret empty
require.NoError(t, db.Model(inbox).Update("webhook_url", "https://example.com/hook").Error)
// secret is empty by default
account, inbox, _ := createWhatsAppInboxTestPrereqs(t, db, "360dialog")
svc.whatsappService = &fakeInboxWhatsAppService{}
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.False(t, result.Healthy)
assert.Equal(t, "misconfigured", result.Status)
assert.Contains(t, result.Details, "secret")
}
func TestInboxService_Health_APIInbox_FullyConfigured(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
// Set both webhook_url and secret
require.NoError(t, db.Model(inbox).Updates(map[string]interface{}{
"webhook_url": "https://example.com/hook",
"secret": "my-hmac-secret",
}).Error)
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.True(t, result.Healthy)
assert.Equal(t, "connected", result.Status)
}
func TestInboxService_Health_WidgetInbox(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "web_widget")
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.Healthy)
assert.Equal(t, "connected", result.Status)
assert.Contains(t, result.Details, "always healthy")
}
func TestInboxService_Health_UnknownChannelType(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "email")
result, err := svc.Health(context.Background(), account.ID, inbox.ID)
require.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.Healthy)
assert.Equal(t, "unknown", result.Status)
assert.Contains(t, result.Details, "not implemented")
require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
assert.Nil(t, result)
}
func TestInboxService_Health_InboxNotFound(t *testing.T) {
@@ -385,65 +377,14 @@ func TestInboxService_SyncTemplates_WhatsAppInbox_NoWaService(t *testing.T) {
// RegisterWebhook service tests
// ========================================
func TestInboxService_RegisterWebhook_APIInbox(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{
URL: "https://example.com/webhook",
Events: []string{"message_created", "conversation_updated"},
})
require.NoError(t, err)
// Verify the inbox webhook_url was updated
var updatedInbox model.Inbox
require.NoError(t, db.First(&updatedInbox, inbox.ID).Error)
assert.Equal(t, "https://example.com/webhook", updatedInbox.WebhookURL)
// Verify a webhook subscription was created
var subs []model.WebhookSubscription
require.NoError(t, db.Where("account_id = ?", account.ID).Find(&subs).Error)
assert.Len(t, subs, 1)
assert.Equal(t, "https://example.com/webhook", subs[0].URL)
assert.True(t, subs[0].Active)
// Verify events are stored correctly
var events []string
require.NoError(t, json.Unmarshal(subs[0].Events, &events))
assert.Contains(t, events, "message_created")
assert.Contains(t, events, "conversation_updated")
}
func TestInboxService_RegisterWebhook_APIInbox_NoEvents(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "api")
err := svc.RegisterWebhook(context.Background(), account.ID, inbox.ID, RegisterWebhookRequest{
URL: "https://example.com/webhook2",
})
require.NoError(t, err)
// Verify the inbox webhook_url was updated
var updatedInbox model.Inbox
require.NoError(t, db.First(&updatedInbox, inbox.ID).Error)
assert.Equal(t, "https://example.com/webhook2", updatedInbox.WebhookURL)
// No webhook subscription should be created when events are empty
var subs []model.WebhookSubscription
require.NoError(t, db.Where("account_id = ?", account.ID).Find(&subs).Error)
assert.Len(t, subs, 0)
}
func TestInboxService_RegisterWebhook_NonAPINonWhatsAppInbox(t *testing.T) {
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",
})
// For channel types other than whatsapp/api, the service returns an error
assert.Error(t, err)
assert.Contains(t, err.Error(), "only supported for WhatsApp and API")
require.ErrorIs(t, err, ErrInboxHealthWhatsAppCloudOnly)
}
func TestInboxService_RegisterWebhook_InboxNotFound(t *testing.T) {
@@ -456,13 +397,23 @@ func TestInboxService_RegisterWebhook_InboxNotFound(t *testing.T) {
assert.Contains(t, err.Error(), "inbox not found")
}
func TestInboxService_RegisterWebhook_WhatsAppInbox_NoWaService(t *testing.T) {
func TestInboxService_RegisterWebhook_WhatsAppCloudUsesExpectedCallbackURL(t *testing.T) {
svc, db := setupInboxServiceTest(t)
account, inbox := createInboxTestPrereqs(t, db, "whatsapp")
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{
URL: "https://example.com/wa-webhook",
})
// Will fail because whatsapp service/repo are nil
assert.Error(t, err)
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)
}