feat(inboxes): align whatsapp health endpoints
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user