Files
gochat/backend/internal/handler/webhook/coverage2_test.go
T

1658 lines
55 KiB
Go

package webhook
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/channel"
linechannel "github.com/gochat/gochat/internal/channel/line"
channelprovider "github.com/gochat/gochat/internal/channel/provider"
telegramchannel "github.com/gochat/gochat/internal/channel/telegram"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/worker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ============================================================
// webhook_handler.go — NewHandler + Handle
// ============================================================
// stubChannelProvider is a minimal ChannelProvider for testing WebhookHandler.Handle.
type stubChannelProvider struct {
providerType channel.ChannelType
incomingMsg *channel.IncomingMessage
err error
}
func (s *stubChannelProvider) Type() channel.ChannelType { return s.providerType }
func (s *stubChannelProvider) Name() string { return string(s.providerType) }
func (s *stubChannelProvider) Description() string { return "stub" }
func (s *stubChannelProvider) ConfigSchema() *channel.ConfigSchemaDefinition { return nil }
func (s *stubChannelProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error {
return nil
}
func (s *stubChannelProvider) DefaultConfig() channel.ChannelConfig { return nil }
func (s *stubChannelProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) {
return config, nil
}
func (s *stubChannelProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error {
return nil
}
func (s *stubChannelProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) {
if s.err != nil {
return nil, s.err
}
if s.incomingMsg != nil {
return s.incomingMsg, nil
}
return &channel.IncomingMessage{SourceID: "stub-src", Content: "stub"}, nil
}
func (s *stubChannelProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) {
return nil, nil
}
func (s *stubChannelProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) {
return nil, nil
}
func (s *stubChannelProvider) Capabilities() channel.ChannelCapabilities {
return channel.ChannelCapabilities{}
}
func (s *stubChannelProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channel.WebhookRequest) error {
return nil
}
func TestNewHandler_Cov2(t *testing.T) {
registry := &channel.ChannelRegistry{}
h := NewHandler(registry)
assert.NotNil(t, h)
}
func TestHandler_Handle_UnknownChannel_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
// Use global registry — it's always initialized
h := NewHandler(channel.GetRegistry())
r := gin.New()
r.POST("/webhooks/:channel_type/:inbox_id", h.Handle)
w := httptest.NewRecorder()
body := bytes.NewReader([]byte(`{}`))
req, _ := http.NewRequest("POST", "/webhooks/unknown_chan_type/1", body)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestHandler_Handle_InvalidInboxID_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
// Register a stub provider on the global registry
provider := &stubChannelProvider{providerType: "stub_channel_test"}
_ = channel.Register(provider)
defer func() {
// Clean up by re-registering won't work, but tests are isolated
}()
h := NewHandler(channel.GetRegistry())
r := gin.New()
r.POST("/webhooks/:channel_type/:inbox_id", h.Handle)
w := httptest.NewRecorder()
body := bytes.NewReader([]byte(`{}`))
req, _ := http.NewRequest("POST", "/webhooks/stub_channel_test/abc", body)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestHandler_Handle_Success_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
// Register a stub provider — Register is idempotent-safe (returns error if already registered)
provider := &stubChannelProvider{providerType: "stub_channel_test2"}
_ = channel.Register(provider)
h := NewHandler(channel.GetRegistry())
r := gin.New()
r.POST("/webhooks/:channel_type/:inbox_id", h.Handle)
w := httptest.NewRecorder()
body := bytes.NewReader([]byte(`{"hello":"world"}`))
req, _ := http.NewRequest("POST", "/webhooks/stub_channel_test2/42", body)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "processed", resp["status"])
}
// ============================================================
// email_webhook.go — all functions
// ============================================================
func TestNewEmailWebhookHandler_Cov2(t *testing.T) {
h := NewEmailWebhookHandler(nil, nil, nil)
assert.NotNil(t, h)
}
func TestEmailWebhookHandleVerification_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewEmailWebhookHandler(nil, nil, nil)
r := gin.New()
r.GET("/verify", h.HandleEmailVerification)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/verify", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestEmailWebhookHandle_InvalidInboxID_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewEmailWebhookHandler(nil, nil, nil)
r := gin.New()
r.POST("/webhooks/email/:inbox_id", h.HandleEmailWebhook)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/email/abc", strings.NewReader(`{}`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestEmailWebhookHandle_InboxNotFound_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewEmailWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/email/:inbox_id", h.HandleEmailWebhook)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/email/999", strings.NewReader(`{}`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestEmailWebhookHandle_PipelineNil_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "email")
// ChannelConfig with valid JSON
inbox.ChannelConfig = `{"mailgun_api_key":"key123"}`
db.Save(&inbox)
h := NewEmailWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/email/:inbox_id", h.HandleEmailWebhook)
// Since pipeline is nil, calling Process will panic; but the body parse will
// succeed first, and pipeline.Process will be nil-pointer — we expect panic recovery
// Instead, test parseChannelConfig and lookupInbox directly
inb, err := h.lookupInbox(inbox.ID)
require.NoError(t, err)
assert.Equal(t, inbox.ID, inb.ID)
cfg := h.parseChannelConfig(inb)
assert.Equal(t, "key123", cfg["mailgun_api_key"])
}
func TestEmailWebhookParseChannelConfig_Empty_Cov2(t *testing.T) {
h := NewEmailWebhookHandler(nil, nil, nil)
cfg := h.parseChannelConfig(&model.Inbox{ChannelConfig: ""})
assert.Empty(t, cfg)
}
func TestEmailWebhookParseChannelConfig_InvalidJSON_Cov2(t *testing.T) {
h := NewEmailWebhookHandler(nil, nil, nil)
cfg := h.parseChannelConfig(&model.Inbox{ChannelConfig: "{invalid"})
assert.Empty(t, cfg)
}
func TestEmailWebhookLookupInbox_NotFound_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewEmailWebhookHandler(nil, nil, db)
_, err := h.lookupInbox(99999)
assert.Error(t, err)
}
// ============================================================
// facebook_webhook.go — WithWorkerPool, WithSearchIndexer, HandleFacebookVerification, resolveVerifyToken
// ============================================================
func TestFacebookWebhookWithWorkerPool_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewFacebookWebhookHandler(nil, nil, db)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.NotNil(t, result)
}
func TestFacebookWebhookWithWorkerPool_NilReceiver_Cov2(t *testing.T) {
var h *FacebookWebhookHandler
wp := worker.NewWorkerPoolWithOptions(newWebhookLookupTestDB(t), worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.Nil(t, result)
}
func TestFacebookWebhookWithSearchIndexer_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewFacebookWebhookHandler(nil, nil, db)
indexer := &recordingIncomingSearchIndexer{}
result := h.WithSearchIndexer(indexer)
assert.NotNil(t, result)
}
func TestFacebookWebhookWithSearchIndexer_NilReceiver_Cov2(t *testing.T) {
var h *FacebookWebhookHandler
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.Nil(t, result)
}
func TestFacebookWebhookHandleVerification_InboxNotFound_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.GET("/webhooks/facebook/:page_id", h.HandleFacebookVerification)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/webhooks/facebook/nonexistent_page?hub.mode=subscribe&hub.verify_token=token&hub.challenge=challenge123", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestFacebookWebhookHandleVerification_NoVerifyToken_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "facebook")
fbChannel := channelmodel.ChannelFacebook{
AccountID: 1,
InboxID: inbox.ID,
PageID: "fb-page-123",
}
require.NoError(t, db.Create(&fbChannel).Error)
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.GET("/webhooks/facebook/:page_id", h.HandleFacebookVerification)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/webhooks/facebook/fb-page-123?hub.mode=subscribe&hub.verify_token=token&hub.challenge=challenge123", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
}
func TestFacebookWebhookHandleVerification_TokenMismatch_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "facebook")
inbox.ChannelConfig = `{"webhook_verify_token":"correct-token"}`
require.NoError(t, db.Save(&inbox).Error)
fbChannel := channelmodel.ChannelFacebook{
AccountID: 1,
InboxID: inbox.ID,
PageID: "fb-page-456",
}
require.NoError(t, db.Create(&fbChannel).Error)
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.GET("/webhooks/facebook/:page_id", h.HandleFacebookVerification)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/webhooks/facebook/fb-page-456?hub.mode=subscribe&hub.verify_token=wrong-token&hub.challenge=challenge123", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestFacebookWebhookHandleVerification_Success_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "facebook")
inbox.ChannelConfig = `{"webhook_verify_token":"correct-token"}`
require.NoError(t, db.Save(&inbox).Error)
fbChannel := channelmodel.ChannelFacebook{
AccountID: 1,
InboxID: inbox.ID,
PageID: "fb-page-789",
}
require.NoError(t, db.Create(&fbChannel).Error)
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.GET("/webhooks/facebook/:page_id", h.HandleFacebookVerification)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/webhooks/facebook/fb-page-789?hub.mode=subscribe&hub.verify_token=correct-token&hub.challenge=challenge789", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "challenge789", w.Body.String())
}
func TestFacebookWebhookResolveVerifyToken_Cov2(t *testing.T) {
h := NewFacebookWebhookHandler(nil, nil, nil)
// With token
token := h.resolveVerifyToken(&model.Inbox{ChannelConfig: `{"webhook_verify_token":"my-token"}`})
assert.Equal(t, "my-token", token)
// Without token
token = h.resolveVerifyToken(&model.Inbox{ChannelConfig: ``})
assert.Equal(t, "", token)
// Invalid JSON
token = h.resolveVerifyToken(&model.Inbox{ChannelConfig: `{invalid`})
assert.Equal(t, "", token)
}
func TestFacebookWebhookResolveAppSecret_Cov2(t *testing.T) {
h := NewFacebookWebhookHandler(nil, nil, nil)
secret := h.resolveAppSecret(&model.Inbox{ChannelConfig: `{"app_secret":"my-secret"}`})
assert.Equal(t, "my-secret", secret)
secret = h.resolveAppSecret(&model.Inbox{ChannelConfig: ``})
assert.Equal(t, "", secret)
}
func TestFacebookWebhookParseChannelConfig_Cov2(t *testing.T) {
h := NewFacebookWebhookHandler(nil, nil, nil)
// Valid
cfg := h.parseChannelConfig(&model.Inbox{ChannelConfig: `{"key":"value"}`})
assert.Equal(t, "value", cfg["key"])
// Empty
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: ``})
assert.Empty(t, cfg)
// Invalid
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: `{bad json`})
assert.Empty(t, cfg)
}
func TestFacebookWebhookHandleWebhook_InboxNotFound_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/facebook/:page_id", h.HandleFacebookWebhook)
w := httptest.NewRecorder()
body := []byte(`{"object":"page","entry":[]}`)
req, _ := http.NewRequest("POST", "/webhooks/facebook/nonexistent", bytes.NewReader(body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestParseOptionalUintParam_Cov2(t *testing.T) {
// Empty
v, err := parseOptionalUintParam("")
assert.NoError(t, err)
assert.Equal(t, uint(0), v)
// Valid
v, err = parseOptionalUintParam("42")
assert.NoError(t, err)
assert.Equal(t, uint(42), v)
// Invalid
_, err = parseOptionalUintParam("abc")
assert.Error(t, err)
}
func TestMillisToTime_Cov2(t *testing.T) {
// Zero/negative → nil
t1 := millisToTime(0)
assert.Nil(t, t1)
t2 := millisToTime(-1)
assert.Nil(t, t2)
// Positive → valid time
t3 := millisToTime(1710000000000)
assert.NotNil(t, t3)
}
// ============================================================
// line_webhook.go — WithWorkerPool, WithSearchIndexer, HandleLineVerification, lookupInbox
// ============================================================
func TestLineWebhookWithWorkerPool_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
lineRepo := linechannel.NewRepository(db)
lineSvc := linechannel.NewLineService(lineRepo)
h := NewLineWebhookHandler(nil, nil, lineSvc, db)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.NotNil(t, result)
}
func TestLineWebhookWithWorkerPool_NilReceiver_Cov2(t *testing.T) {
var h *LineWebhookHandler
wp := worker.NewWorkerPoolWithOptions(newWebhookLookupTestDB(t), worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.Nil(t, result)
}
func TestLineWebhookWithSearchIndexer_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
lineRepo := linechannel.NewRepository(db)
lineSvc := linechannel.NewLineService(lineRepo)
h := NewLineWebhookHandler(nil, nil, lineSvc, db)
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.NotNil(t, result)
}
func TestLineWebhookWithSearchIndexer_NilReceiver_Cov2(t *testing.T) {
var h *LineWebhookHandler
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.Nil(t, result)
}
func TestLineWebhookHandleVerification_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewLineWebhookHandler(nil, nil, nil, nil)
r := gin.New()
r.GET("/verify", h.HandleLineVerification)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/verify", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestLineWebhookLookupInbox_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "line")
h := NewLineWebhookHandler(nil, nil, nil, db)
found, err := h.lookupInbox(inbox.ID)
require.NoError(t, err)
assert.Equal(t, inbox.ID, found.ID)
_, err = h.lookupInbox(99999)
assert.Error(t, err)
}
func TestLineWebhookLookupInboxByLineChannelID_NotFound_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewLineWebhookHandler(nil, nil, nil, db)
_, err := h.lookupInboxByLineChannelID("nonexistent")
assert.Error(t, err)
}
func TestLineWebhookLookupInboxByLineChannelID_NilDB_Cov2(t *testing.T) {
h := NewLineWebhookHandler(nil, nil, nil, nil)
_, err := h.lookupInboxByLineChannelID("some-id")
assert.Error(t, err)
}
func TestLineWebhookParseChannelConfig_Cov2(t *testing.T) {
h := NewLineWebhookHandler(nil, nil, nil, nil)
// Empty
cfg := h.parseChannelConfig(&model.Inbox{ChannelConfig: ""})
assert.Empty(t, cfg)
// Valid
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: `{"channel_secret":"secret123"}`})
assert.Equal(t, "secret123", cfg["channel_secret"])
// Invalid
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: `{bad`})
assert.Empty(t, cfg)
}
func TestLineWebhookHandle_MissingChannelID_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewLineWebhookHandler(nil, nil, nil, nil)
// Use a route without the line_channel_id param so it's empty
r := gin.New()
r.POST("/webhooks/line", h.HandleLineWebhook)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/line", strings.NewReader(`{}`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// ============================================================
// telegram_webhook.go — WithWorkerPool, WithSearchIndexer
// ============================================================
func TestTelegramWebhookWithWorkerPool_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
telProvider := channelprovider.NewTelegramProvider()
telWebhook := telegramchannel.NewWebhookHandler(telProvider)
h := NewTelegramWebhookHandler(telProvider, telWebhook, db)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.NotNil(t, result)
}
func TestTelegramWebhookWithWorkerPool_NilReceiver_Cov2(t *testing.T) {
var h *TelegramWebhookHandler
wp := worker.NewWorkerPoolWithOptions(newWebhookLookupTestDB(t), worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.Nil(t, result)
}
func TestTelegramWebhookWithSearchIndexer_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
telProvider := channelprovider.NewTelegramProvider()
telWebhook := telegramchannel.NewWebhookHandler(telProvider)
h := NewTelegramWebhookHandler(telProvider, telWebhook, db)
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.NotNil(t, result)
}
func TestTelegramWebhookWithSearchIndexer_NilReceiver_Cov2(t *testing.T) {
var h *TelegramWebhookHandler
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.Nil(t, result)
}
func TestTelegramWebhookHandle_NoBotToken_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewTelegramWebhookHandler(nil, nil, nil)
r := gin.New()
r.POST("/webhooks/telegram", h.HandleTelegramWebhook)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/telegram", strings.NewReader(`{}`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestTelegramWebhookHandle_InvalidJSON_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewTelegramWebhookHandler(nil, nil, nil)
r := gin.New()
r.POST("/webhooks/telegram/:bot_token", h.HandleTelegramWebhook)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/telegram/some-token", strings.NewReader(`{invalid json`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestTelegramWebhookHandle_InboxNotFound_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewTelegramWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/telegram/:bot_token", h.HandleTelegramWebhook)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/telegram/nonexistent-token", strings.NewReader(`{"update_id":1}`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestTelegramWebhookLookupInbox_NilDB_Cov2(t *testing.T) {
h := NewTelegramWebhookHandler(nil, nil, nil)
_, err := h.lookupInbox("some-token")
assert.Error(t, err)
}
func TestTelegramWebhookMaskBotToken_Cov2(t *testing.T) {
// Long token
masked := maskBotToken("1234567890:ABC-DEF")
assert.Equal(t, "12345678...", masked)
// Short token
masked = maskBotToken("short")
assert.Equal(t, "short", masked)
}
// ============================================================
// tiktok_webhook.go — WithWorkerPool, WithSearchIndexer, HandleTikTokVerification, lookupInbox, parseChannelConfig
// ============================================================
func TestTikTokWebhookWithWorkerPool_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewTikTokWebhookHandler(nil, nil, db)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.NotNil(t, result)
}
func TestTikTokWebhookWithWorkerPool_NilReceiver_Cov2(t *testing.T) {
var h *TikTokWebhookHandler
wp := worker.NewWorkerPoolWithOptions(newWebhookLookupTestDB(t), worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.Nil(t, result)
}
func TestTikTokWebhookWithSearchIndexer_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewTikTokWebhookHandler(nil, nil, db)
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.NotNil(t, result)
}
func TestTikTokWebhookWithSearchIndexer_NilReceiver_Cov2(t *testing.T) {
var h *TikTokWebhookHandler
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.Nil(t, result)
}
func TestTikTokWebhookHandleVerification_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewTikTokWebhookHandler(nil, nil, nil)
r := gin.New()
r.POST("/webhooks/tiktok/verify", h.HandleTikTokVerification)
w := httptest.NewRecorder()
body := `{"challenge":"my-challenge"}`
req, _ := http.NewRequest("POST", "/webhooks/tiktok/verify", strings.NewReader(body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "my-challenge", resp["challenge"])
}
func TestTikTokWebhookHandleVerification_InvalidJSON_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewTikTokWebhookHandler(nil, nil, nil)
r := gin.New()
r.POST("/webhooks/tiktok/verify", h.HandleTikTokVerification)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/tiktok/verify", strings.NewReader(`{invalid`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestTikTokWebhookHandleVerification_ReadBodyFail_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewTikTokWebhookHandler(nil, nil, nil)
r := gin.New()
r.POST("/webhooks/tiktok/verify", h.HandleTikTokVerification)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/tiktok/verify", &failingReader{})
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
}
func TestTikTokWebhookLookupInbox_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "tiktok")
h := NewTikTokWebhookHandler(nil, nil, db)
found, err := h.lookupInbox(inbox.ID)
require.NoError(t, err)
assert.Equal(t, inbox.ID, found.ID)
_, err = h.lookupInbox(99999)
assert.Error(t, err)
}
func TestTikTokWebhookLookupInboxByBusinessID_NilDB_Cov2(t *testing.T) {
h := NewTikTokWebhookHandler(nil, nil, nil)
_, err := h.lookupInboxByBusinessID("biz-123")
assert.Error(t, err)
}
func TestTikTokWebhookParseChannelConfig_Cov2(t *testing.T) {
h := NewTikTokWebhookHandler(nil, nil, nil)
// Empty
cfg := h.parseChannelConfig(&model.Inbox{ChannelConfig: ""})
assert.Empty(t, cfg)
// Valid
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: `{"key":"val"}`})
assert.Equal(t, "val", cfg["key"])
// Invalid
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: `{bad`})
assert.Empty(t, cfg)
}
func TestExtractTikTokBusinessID_Cov2(t *testing.T) {
tests := []struct {
json string
want string
}{
{`{"biz_id":"b1"}`, "b1"},
{`{"business_id":"b2"}`, "b2"},
{`{"tiktok_business_id":"b3"}`, "b3"},
{`{"data":{"biz_id":"b4"}}`, "b4"},
{`{"data":{"business_id":"b5"}}`, "b5"},
{`{"data":{"tiktok_business_id":"b6"}}`, "b6"},
{`{}`, ""},
{`invalid`, ""},
}
for _, tt := range tests {
got := extractTikTokBusinessID([]byte(tt.json))
assert.Equal(t, tt.want, got, "input: %s", tt.json)
}
}
func TestExtractTikTokSignatureParts_Cov2(t *testing.T) {
// Empty
ts, sig := extractTikTokSignatureParts("")
assert.Equal(t, int64(0), ts)
assert.Equal(t, "", sig)
// Valid
ts, sig = extractTikTokSignatureParts("t=1710000000,s=abc123")
assert.Equal(t, int64(1710000000), ts)
assert.Equal(t, "abc123", sig)
// Malformed
ts, sig = extractTikTokSignatureParts("garbage")
assert.Equal(t, int64(0), ts)
assert.Equal(t, "", sig)
}
func TestTiktokDataString_Cov2(t *testing.T) {
data := map[string]interface{}{"message_id": "msg-1", "count": float64(42)}
assert.Equal(t, "msg-1", tiktokDataString(data, "message_id"))
assert.Equal(t, "", tiktokDataString(data, "count")) // not a string
assert.Equal(t, "", tiktokDataString(data, "missing"))
}
func TestVerifyTikTokSignature_MissingCredentials_Cov2(t *testing.T) {
t.Setenv("TIKTOK_APP_SECRET", "")
err := verifyTikTokSignature("t=1710000000,s=abc", []byte(`{}`), time.Now())
assert.Error(t, err)
}
// ============================================================
// twilio_webhook.go — WithSearchIndexer, lookupInbox
// ============================================================
func TestTwilioWebhookWithSearchIndexer_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewTwilioWebhookHandler(nil, db)
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.NotNil(t, result)
}
func TestTwilioWebhookWithSearchIndexer_NilReceiver_Cov2(t *testing.T) {
var h *TwilioWebhookHandler
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.Nil(t, result)
}
func TestTwilioWebhookLookupInbox_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "twilio_sms")
h := NewTwilioWebhookHandler(nil, db)
found, err := h.lookupInbox(inbox.ID)
require.NoError(t, err)
assert.Equal(t, inbox.ID, found.ID)
_, err = h.lookupInbox(99999)
assert.Error(t, err)
}
func TestTwilioWebhookLookupInboxByPhoneNumber_NilDB_Cov2(t *testing.T) {
h := NewTwilioWebhookHandler(nil, nil)
_, err := h.lookupInboxByPhoneNumber("+15551234567")
assert.Error(t, err)
}
func TestTwilioWebhookHandleInboundSMS_MissingPhone_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewTwilioWebhookHandler(nil, nil)
r := gin.New()
r.POST("/webhooks/sms", h.HandleTwilioInboundSMS)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/sms", strings.NewReader(""))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "<Response>")
}
func TestTwilioWebhookHandleInboundSMS_InboxNotFound_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewTwilioWebhookHandler(nil, db)
r := gin.New()
r.POST("/webhooks/sms/:phone_number", h.HandleTwilioInboundSMS)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/sms/15551234567", strings.NewReader(""))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestTwilioWebhookHandleCallback_NoWebhook_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewTwilioWebhookHandler(nil, db)
r := gin.New()
r.POST("/webhooks/twilio/callback", h.HandleTwilioCallback)
w := httptest.NewRecorder()
form := url.Values{}
req, _ := http.NewRequest("POST", "/webhooks/twilio/callback", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestTwilioWebhookHandleCallback_InboxNotFound_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewTwilioWebhookHandler(nil, db)
r := gin.New()
r.POST("/webhooks/twilio/callback", h.HandleTwilioCallback)
w := httptest.NewRecorder()
form := url.Values{
"To": []string{"+15551234567"},
"AccountSid": []string{"AC123"},
"MessageSid": []string{"SM123"},
"MessageStatus": []string{"sent"},
}
req, _ := http.NewRequest("POST", "/webhooks/twilio/callback", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestTwilioWebhookHandleDeliveryStatus_NoPhone_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewTwilioWebhookHandler(nil, db)
r := gin.New()
r.POST("/webhooks/twilio/status", h.HandleTwilioDeliveryStatus)
w := httptest.NewRecorder()
form := url.Values{
"MessageSid": []string{"SM123"},
"MessageStatus": []string{"delivered"},
}
req, _ := http.NewRequest("POST", "/webhooks/twilio/status", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestTwilioNormalizePhone_Cov2(t *testing.T) {
assert.Equal(t, "+15551234567", normalizeTwilioPhone("15551234567"))
assert.Equal(t, "+15551234567", normalizeTwilioPhone("+15551234567"))
assert.Equal(t, "", normalizeTwilioPhone(" "))
}
func TestTwilioMapMessageStatus_Cov2(t *testing.T) {
tests := []struct {
status string
want model.MessageStatus
ok bool
}{
{"sent", model.MessageStatusSent, true},
{"queued", model.MessageStatusSent, true},
{"accepted", model.MessageStatusSent, true},
{"sending", model.MessageStatusSent, true},
{"delivered", model.MessageStatusDelivered, true},
{"read", model.MessageStatusRead, true},
{"undelivered", model.MessageStatusFailed, true},
{"failed", model.MessageStatusFailed, true},
{"unknown", "", false},
}
for _, tt := range tests {
got, ok := mapTwilioMessageStatus(tt.status)
assert.Equal(t, tt.ok, ok, "status: %s", tt.status)
assert.Equal(t, tt.want, got, "status: %s", tt.status)
}
}
func TestTwilioExternalError_Cov2(t *testing.T) {
// Failed with error message
assert.Equal(t, "30007 - Carrier violation", twilioExternalError("30007", "Carrier violation", "failed"))
// Undelivered with error message
assert.Equal(t, "30008 - Rejected", twilioExternalError("30008", "Rejected", "undelivered"))
// Failed without error message
assert.Equal(t, "Twilio delivery failed with error code 30007", twilioExternalError("30007", "", "failed"))
// Non-failed status → empty
assert.Equal(t, "", twilioExternalError("30007", "msg", "delivered"))
// No error code → empty
assert.Equal(t, "", twilioExternalError("", "msg", "failed"))
}
// ============================================================
// whatsapp_webhook.go — WithWorkerPool, WithSearchIndexer, UpdateMessageStatus adapter
// ============================================================
func TestWhatsAppWebhookWithWorkerPool_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewWhatsAppWebhookHandler(nil, nil, db)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.NotNil(t, result)
}
func TestWhatsAppWebhookWithWorkerPool_NilReceiver_Cov2(t *testing.T) {
var h *WhatsAppWebhookHandler
wp := worker.NewWorkerPoolWithOptions(newWebhookLookupTestDB(t), worker.WithQueues("low"))
result := h.WithWorkerPool(wp)
assert.Nil(t, result)
}
func TestWhatsAppWebhookWithSearchIndexer_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewWhatsAppWebhookHandler(nil, nil, db)
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.NotNil(t, result)
}
func TestWhatsAppWebhookWithSearchIndexer_NilReceiver_Cov2(t *testing.T) {
var h *WhatsAppWebhookHandler
result := h.WithSearchIndexer(&recordingIncomingSearchIndexer{})
assert.Nil(t, result)
}
func TestWhatsAppPersisterAdapter_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
persister := NewIncomingPersister(db)
adapter := whatsAppPersisterAdapter{persister: persister}
// UpdateMessageStatus with non-existent source ID — should not error
err := adapter.UpdateMessageStatus(context.Background(), &model.Inbox{}, "nonexistent", model.MessageStatusRead, nil)
assert.NoError(t, err)
// UpdateMessageStatusWithError
err = adapter.UpdateMessageStatusWithError(context.Background(), &model.Inbox{}, "nonexistent", model.MessageStatusFailed, nil, "test error")
assert.NoError(t, err)
// PersistIncoming with valid msg to exercise the adapter delegation path
msg := &channel.IncomingMessage{
ChannelType: channel.ChannelTelegram,
SourceID: "wa-adapter-1",
SenderID: "wa-user",
SenderName: "WA User",
SenderType: channel.SenderContact,
Content: "adapter test",
ContentType: channel.ContentText,
}
_, err = adapter.PersistIncoming(context.Background(), &model.Inbox{AccountID: 1}, msg)
// With nil inbox ID=0, this may error during conversation creation — just ensure no panic
_ = err
}
// ============================================================
// shopify_webhook.go — findShopifyHookByDomain
// ============================================================
func TestShopifyWebhookFindHookByDomain_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
settings, _ := json.Marshal(model.ShopifySettings{ShopDomain: "store1.myshopify.com"})
hook := model.IntegrationHook{
AccountID: 1,
HookType: model.HookTypeShopify,
Status: model.HookStatusActive,
AccessToken: "token",
Settings: settings,
}
require.NoError(t, db.Create(&hook).Error)
h := NewShopifyWebhookHandler(db, "secret")
found, err := h.findShopifyHookByDomain(context.Background(), "store1.myshopify.com")
require.NoError(t, err)
assert.NotNil(t, found)
assert.Equal(t, hook.ID, found.ID)
}
func TestShopifyWebhookFindHookByDomain_NotFound_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
h := NewShopifyWebhookHandler(db, "secret")
found, err := h.findShopifyHookByDomain(context.Background(), "nonexistent.myshopify.com")
// When no hooks match, shopifyHooks returns empty list with nil error,
// and findShopifyHookByDomain returns nil, nil
assert.NoError(t, err)
assert.Nil(t, found)
}
func TestShopifyWebhookFindHookByDomain_NilDB_Cov2(t *testing.T) {
h := NewShopifyWebhookHandler(nil, "secret")
_, err := h.findShopifyHookByDomain(context.Background(), "store.myshopify.com")
assert.Error(t, err)
}
func TestShopifyWebhookHandle_EmptyBody_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewShopifyWebhookHandler(db, "secret")
r := gin.New()
r.POST("/webhooks/shopify", h.HandleShopifyWebhook)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/shopify", strings.NewReader(""))
req.Header.Set("X-Shopify-Hmac-SHA256", shopifyHMAC("secret", []byte("")))
req.Header.Set("X-Shopify-Topic", "orders/create")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestShopifyWebhookHandle_InvalidJSON_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewShopifyWebhookHandler(db, "secret")
r := gin.New()
r.POST("/webhooks/shopify", h.HandleShopifyWebhook)
body := []byte(`{invalid}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/shopify", bytes.NewReader(body))
req.Header.Set("X-Shopify-Hmac-SHA256", shopifyHMAC("secret", body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestShopifyWebhookHandle_NoSecret_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewShopifyWebhookHandler(db, "")
r := gin.New()
r.POST("/webhooks/shopify", h.HandleShopifyWebhook)
// Without SHOPIFY_CLIENT_SECRET env var and empty secret
t.Setenv("SHOPIFY_CLIENT_SECRET", "")
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/shopify", strings.NewReader(`{}`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestShopifyWebhookHandle_ReadBodyFail_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewShopifyWebhookHandler(db, "secret")
r := gin.New()
r.POST("/webhooks/shopify", h.HandleShopifyWebhook)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/shopify", &failingReader{})
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// ============================================================
// Helpers
// ============================================================
// failingReader is an io.Reader that always fails.
type failingReader struct{}
func (f *failingReader) Read(p []byte) (int, error) {
return 0, http.ErrBodyReadAfterClose
}
// ============================================================
// Additional incoming_persister helper coverage
// ============================================================
func TestMapIncomingContentType_Cov2(t *testing.T) {
tests := []struct {
ct channel.ContentType
want string
}{
{channel.ContentImage, string(model.MessageContentTypeImage)},
{channel.ContentAudio, string(model.MessageContentTypeAudio)},
{channel.ContentVideo, string(model.MessageContentTypeVideo)},
{channel.ContentFile, string(model.MessageContentTypeFile)},
{channel.ContentLocation, string(model.MessageContentTypeLocation)},
{channel.ContentText, string(model.MessageContentTypeText)},
{channel.ContentType("unknown"), string(model.MessageContentTypeText)},
}
for _, tt := range tests {
got := mapIncomingContentType(tt.ct)
assert.Equal(t, tt.want, got)
}
}
func TestMergeChannelConfig_Cov2(t *testing.T) {
base := channel.ChannelConfig{"key1": "val1", "key2": "val2"}
extra := map[string]interface{}{"key2": "override", "key3": "val3", "empty": ""}
merged := mergeChannelConfig(base, extra)
assert.Equal(t, "val1", merged["key1"])
assert.Equal(t, "override", merged["key2"])
assert.Equal(t, "val3", merged["key3"])
_, hasEmpty := merged["empty"]
assert.False(t, hasEmpty)
}
func TestMustJSON_Cov2(t *testing.T) {
// Nil → empty JSON
j := mustJSON(nil)
assert.Equal(t, `{}`, string(j)) // datatypes.JSON marshals nil as {"result":null}? Actually nil → "{}"
// Valid value
j = mustJSON(map[string]string{"key": "val"})
assert.Contains(t, string(j), "key")
// Value that can't be marshaled (channel/func)
j = mustJSON(make(chan int))
assert.Equal(t, "{}", string(j))
}
func TestValidateIncomingMessage_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
p := NewIncomingPersister(db)
// Nil persister
assert.NoError(t, validateIncomingMessage(nil, &model.Inbox{}, &channel.IncomingMessage{}))
// Nil inbox
assert.NoError(t, validateIncomingMessage(p, nil, &channel.IncomingMessage{}))
// Nil msg
assert.NoError(t, validateIncomingMessage(p, &model.Inbox{}, nil))
// Missing source_id
err := validateIncomingMessage(p, &model.Inbox{}, &channel.IncomingMessage{SenderID: "s1", Content: "hello"})
assert.Error(t, err)
// Missing sender_id and conversation_id
err = validateIncomingMessage(p, &model.Inbox{}, &channel.IncomingMessage{SourceID: "src1", Content: "hello"})
assert.Error(t, err)
// Missing content and attachments
err = validateIncomingMessage(p, &model.Inbox{}, &channel.IncomingMessage{SourceID: "src1", SenderID: "s1"})
assert.Error(t, err)
// Valid with conversation_id as fallback for sender_id
err = validateIncomingMessage(p, &model.Inbox{}, &channel.IncomingMessage{SourceID: "src1", ConversationID: "conv1", Content: "hello"})
assert.NoError(t, err)
// Fully valid
err = validateIncomingMessage(p, &model.Inbox{}, &channel.IncomingMessage{SourceID: "src1", SenderID: "s1", Content: "hello"})
assert.NoError(t, err)
}
func TestPersistIncoming_NilDB_Cov2(t *testing.T) {
p := &IncomingPersister{}
result, err := p.PersistIncoming(context.Background(), &model.Inbox{}, &channel.IncomingMessage{})
assert.Nil(t, result)
assert.NoError(t, err)
}
func TestPerformPersistIncoming_NilParams_Cov2(t *testing.T) {
p := &IncomingPersister{}
result, err := p.performPersistIncoming(context.Background(), nil, nil)
assert.Nil(t, result)
assert.NoError(t, err)
}
// ============================================================
// Twilio callback with successful inbox lookup
// ============================================================
func TestTwilioWebhookHandleCallback_Success_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "twilio_sms")
twilioChannel := channelmodel.ChannelTwilioSMS{
AccountID: inbox.AccountID,
InboxID: inbox.ID,
AccountSID: "AC123",
PhoneNumber: "+15551234567",
}
require.NoError(t, db.Create(&twilioChannel).Error)
h := NewTwilioWebhookHandler(nil, db)
r := gin.New()
r.POST("/webhooks/twilio/callback", h.HandleTwilioCallback)
w := httptest.NewRecorder()
form := url.Values{
"To": []string{"+15551234567"},
"AccountSid": []string{"AC123"},
"MessageSid": []string{"SM123"},
"MessageStatus": []string{"sent"},
"Body": []string{"hello"},
"From": []string{"+15557654321"},
}
req, _ := http.NewRequest("POST", "/webhooks/twilio/callback", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
// twilioWebhook is nil, so ProcessInboundSMS will fail, but handler returns 204
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestTwilioWebhookHandleCallback_MessagingServiceSid_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "twilio_sms")
twilioChannel := channelmodel.ChannelTwilioSMS{
AccountID: inbox.AccountID,
InboxID: inbox.ID,
AccountSID: "AC456",
PhoneNumber: "+15559998888",
MessagingServiceSID: "MG789",
}
require.NoError(t, db.Create(&twilioChannel).Error)
h := NewTwilioWebhookHandler(nil, db)
r := gin.New()
r.POST("/webhooks/twilio/callback", h.HandleTwilioCallback)
w := httptest.NewRecorder()
form := url.Values{
"MessagingServiceSid": []string{"MG789"},
"MessageSid": []string{"SM456"},
"MessageStatus": []string{"sent"},
"Body": []string{"test"},
"From": []string{"+15551112222"},
}
req, _ := http.NewRequest("POST", "/webhooks/twilio/callback", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestTwilioWebhookHandleDeliveryStatus_MessagingServiceSid_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "twilio_sms")
twilioChannel := channelmodel.ChannelTwilioSMS{
AccountID: inbox.AccountID,
InboxID: inbox.ID,
AccountSID: "AC789",
PhoneNumber: "+15550000000",
MessagingServiceSID: "MG111",
}
require.NoError(t, db.Create(&twilioChannel).Error)
h := NewTwilioWebhookHandler(nil, db)
r := gin.New()
r.POST("/webhooks/twilio/status", h.HandleTwilioDeliveryStatus)
w := httptest.NewRecorder()
form := url.Values{
"MessagingServiceSid": []string{"MG111"},
"MessageSid": []string{"SM789"},
"MessageStatus": []string{"delivered"},
}
req, _ := http.NewRequest("POST", "/webhooks/twilio/status", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestTwilioWebhookHandleDeliveryStatus_AccountSidFrom_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "twilio_sms")
twilioChannel := channelmodel.ChannelTwilioSMS{
AccountID: inbox.AccountID,
InboxID: inbox.ID,
AccountSID: "AC222",
PhoneNumber: "+15553334444",
}
require.NoError(t, db.Create(&twilioChannel).Error)
h := NewTwilioWebhookHandler(nil, db)
r := gin.New()
r.POST("/webhooks/twilio/status", h.HandleTwilioDeliveryStatus)
w := httptest.NewRecorder()
form := url.Values{
"AccountSid": []string{"AC222"},
"From": []string{"+15553334444"},
"MessageSid": []string{"SM999"},
"MessageStatus": []string{"failed"},
"ErrorCode": []string{"30007"},
"ErrorMessage": []string{"Carrier violation"},
}
req, _ := http.NewRequest("POST", "/webhooks/twilio/status", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
}
// ============================================================
// Telegram webhook with successful inbox lookup
// ============================================================
func TestTelegramWebhookHandle_Success_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "telegram")
telChannel := channelmodel.ChannelTelegram{
AccountID: inbox.AccountID,
InboxID: inbox.ID,
BotToken: "test-bot-token",
}
require.NoError(t, db.Create(&telChannel).Error)
telProvider := channelprovider.NewTelegramProvider()
telWebhook := telegramchannel.NewWebhookHandler(telProvider)
h := NewTelegramWebhookHandler(telProvider, telWebhook, db)
r := gin.New()
r.POST("/webhooks/telegram/:bot_token", h.HandleTelegramWebhook)
w := httptest.NewRecorder()
body := `{"update_id":1,"message":{"message_id":1,"from":{"id":123,"first_name":"Test"},"chat":{"id":123,"first_name":"Test"},"date":1710000000,"text":"hello telegram"}}`
req, _ := http.NewRequest("POST", "/webhooks/telegram/test-bot-token", strings.NewReader(body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// ============================================================
// LINE webhook with inbox lookup and valid body
// ============================================================
func TestLineWebhookHandle_InboxNotFound_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
lineRepo := linechannel.NewRepository(db)
lineSvc := linechannel.NewLineService(lineRepo)
h := NewLineWebhookHandler(nil, nil, lineSvc, db)
r := gin.New()
r.POST("/webhooks/line/:line_channel_id", h.HandleLineWebhook)
w := httptest.NewRecorder()
body := `{"events":[]}`
req, _ := http.NewRequest("POST", "/webhooks/line/nonexistent-channel-id", strings.NewReader(body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestLineWebhookHandle_ValidInbox_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "line")
lineChan := channelmodel.ChannelLINE{
AccountID: inbox.AccountID,
InboxID: inbox.ID,
ChannelID: "line-channel-123",
}
require.NoError(t, db.Create(&lineChan).Error)
lineRepo := linechannel.NewRepository(db)
lineSvc := linechannel.NewLineService(lineRepo)
h := NewLineWebhookHandler(nil, nil, lineSvc, db)
r := gin.New()
r.POST("/webhooks/line/:line_channel_id", h.HandleLineWebhook)
w := httptest.NewRecorder()
body := `{"events":[]}`
req, _ := http.NewRequest("POST", "/webhooks/line/line-channel-123", strings.NewReader(body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestLineWebhookHandle_InvalidJSON_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "line")
lineChan := channelmodel.ChannelLINE{
AccountID: inbox.AccountID,
InboxID: inbox.ID,
ChannelID: "line-channel-456",
}
require.NoError(t, db.Create(&lineChan).Error)
h := NewLineWebhookHandler(nil, nil, nil, db)
r := gin.New()
r.POST("/webhooks/line/:line_channel_id", h.HandleLineWebhook)
w := httptest.NewRecorder()
body := `{invalid json`
req, _ := http.NewRequest("POST", "/webhooks/line/line-channel-456", strings.NewReader(body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// ============================================================
// Facebook HandleFacebookWebhook with valid inbox but nil providers
// ============================================================
func TestFacebookWebhookHandle_ValidInbox_NilProviders_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "facebook")
fbChannel := channelmodel.ChannelFacebook{
AccountID: 1,
InboxID: inbox.ID,
PageID: "fb-page-webhook-test",
}
require.NoError(t, db.Create(&fbChannel).Error)
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/facebook/:page_id", h.HandleFacebookWebhook)
w := httptest.NewRecorder()
body := []byte(`{"object":"page","entry":[{"id":"fb-page-webhook-test","time":1,"messaging":[{"sender":{"id":"user1"},"recipient":{"id":"fb-page-webhook-test"},"timestamp":1,"message":{"mid":"mid-fb-1","text":"hello fb"}}]}]}`)
req, _ := http.NewRequest("POST", "/webhooks/facebook/fb-page-webhook-test", bytes.NewReader(body))
r.ServeHTTP(w, req)
// Should return 200 (FB always gets 200)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestFacebookWebhookHandle_InvalidPayload_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "facebook")
fbChannel := channelmodel.ChannelFacebook{
AccountID: 1,
InboxID: inbox.ID,
PageID: "fb-page-invalid-payload",
}
require.NoError(t, db.Create(&fbChannel).Error)
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/facebook/:page_id", h.HandleFacebookWebhook)
w := httptest.NewRecorder()
body := []byte(`{invalid json}`)
req, _ := http.NewRequest("POST", "/webhooks/facebook/fb-page-invalid-payload", bytes.NewReader(body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// ============================================================
// Email webhook with full pipeline (nil pipeline → parse error path)
// ============================================================
func TestEmailWebhookHandle_BodyReadError_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "email")
h := NewEmailWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/email/:inbox_id", h.HandleEmailWebhook)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/email/"+itoa(int(inbox.ID)), &failingReader{})
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// ============================================================
// Instagram webhook with non-instagram object
// ============================================================
func TestInstagramWebhookHandle_NonInstagramObject_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
t.Setenv("INSTAGRAM_APP_SECRET", "ig-secret")
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/instagram", h.HandleInstagramWebhook)
body := []byte(`{"object":"page","entry":[{"id":"x","time":1,"messaging":[]}]}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/instagram", bytes.NewReader(body))
req.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestInstagramWebhookHandle_EmptyEvents_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
t.Setenv("INSTAGRAM_APP_SECRET", "ig-secret")
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/instagram", h.HandleInstagramWebhook)
body := []byte(`{"object":"instagram","entry":[]}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/instagram", bytes.NewReader(body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// ============================================================
// TikTok webhook verification via path param
// ============================================================
func TestTikTokWebhookHandle_MissingBusinessID_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
db := newWebhookLookupTestDB(t)
h := NewTikTokWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/tiktok", h.HandleTikTokWebhook)
// Body without biz_id and no path param
body := []byte(`{"type":"message.received","data":{"message_id":"x"}}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/tiktok", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Tiktok-Signature", tiktokSignature("tiktok-secret", time.Now().Unix(), body))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// ============================================================
// Shopify webhook with event processing (hook found)
// ============================================================
func TestShopifyWebhookHandle_EventProcessing_Cov2(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
settings, _ := json.Marshal(model.ShopifySettings{ShopDomain: "event-store.myshopify.com"})
hook := model.IntegrationHook{
AccountID: 1,
HookType: model.HookTypeShopify,
Status: model.HookStatusActive,
AccessToken: "token",
Settings: settings,
}
require.NoError(t, db.Create(&hook).Error)
h := NewShopifyWebhookHandler(db, "secret")
r := gin.New()
r.POST("/webhooks/shopify", h.HandleShopifyWebhook)
body := []byte(`{"id":12345,"email":"test@example.com"}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/webhooks/shopify", bytes.NewReader(body))
req.Header.Set("X-Shopify-Hmac-SHA256", shopifyHMAC("secret", body))
req.Header.Set("X-Shopify-Topic", "orders/create")
req.Header.Set("X-Shopify-Shop-Domain", "event-store.myshopify.com")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
// ============================================================
// IncomingPersister index helpers
// ============================================================
func TestIndexContact_NilIndexer_Cov2(t *testing.T) {
p := &IncomingPersister{}
// nil indexer → should not panic
p.indexContact(context.Background(), &model.Contact{})
}
func TestIndexConversation_NilIndexer_Cov2(t *testing.T) {
p := &IncomingPersister{}
p.indexConversation(context.Background(), &model.Conversation{})
}
func TestIndexMessage_NilIndexer_Cov2(t *testing.T) {
p := &IncomingPersister{}
p.indexMessage(context.Background(), &model.Message{})
}
func TestIndexIncomingResult_NilIndexer_Cov2(t *testing.T) {
p := &IncomingPersister{}
result := &IncomingPersistResult{
Contact: &model.Contact{},
Conversation: &model.Conversation{},
Message: &model.Message{},
}
p.indexIncomingResult(context.Background(), result)
}
func TestIndexIncomingResult_WithIndexer_Cov2(t *testing.T) {
db := newWebhookLookupTestDB(t)
indexer := &recordingIncomingSearchIndexer{}
p := NewIncomingPersister(db).SetSearchIndexer(indexer)
result := &IncomingPersistResult{
Contact: &model.Contact{Name: "test"},
Conversation: &model.Conversation{},
Message: &model.Message{Content: "test"},
}
p.indexIncomingResult(context.Background(), result)
assert.Len(t, indexer.contacts, 1)
assert.Len(t, indexer.conversations, 1)
assert.Len(t, indexer.messages, 1)
}
func TestDeliveryStatusContactID_NilDB_Cov2(t *testing.T) {
p := &IncomingPersister{}
id, err := p.deliveryStatusContactID(context.Background(), nil, &model.Message{Base: model.Base{ID: 1}})
_ = err
_ = id
}
// itoa is a small helper to avoid importing strconv just for this
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
pos := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
pos--
buf[pos] = byte('0' + n%10)
n /= 10
}
if neg {
pos--
buf[pos] = '-'
}
return string(buf[pos:])
}