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

579 lines
19 KiB
Go

package webhook
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/channel"
fbchannel "github.com/gochat/gochat/internal/channel/facebook"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func init() {
gin.SetMode(gin.TestMode)
}
// safeCall_Boost wraps a function call in recover to handle panics from nil-service handlers.
func safeCall_Boost(t *testing.T, f func()) {
t.Helper()
defer func() { _ = recover() }()
f()
}
func makeGinContext_Boost(method, path string, body []byte, headers map[string]string) (*gin.Context, *httptest.ResponseRecorder) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
} else {
req = httptest.NewRequest(method, path, nil)
}
for k, v := range headers {
req.Header.Set(k, v)
}
c.Request = req
c.Params = gin.Params{}
return c, w
}
func newBoostTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := "file:boost_" + strings.NewReplacer("/", "_", " ", "_", ":", "_").Replace(t.Name()) + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.DeliveryStatus{},
&model.BackgroundJob{},
&channelmodel.ChannelFacebook{},
&channelmodel.ChannelInstagram{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
// === EmailWebhookHandler ===
func TestEmailWebhookHandler_HandleEmailWebhook_Boost_InvalidInboxID(t *testing.T) {
h := &EmailWebhookHandler{}
c, w := makeGinContext_Boost("POST", "/webhooks/email/abc", []byte(`{}`), nil)
c.Params = gin.Params{{Key: "inbox_id", Value: "abc"}}
h.HandleEmailWebhook(c)
assert.Equal(t, http.StatusOK, w.Code) // returns "ignored"
}
func TestEmailWebhookHandler_HandleEmailWebhook_Boost_InboxNotFound(t *testing.T) {
db := newBoostTestDB(t)
h := &EmailWebhookHandler{db: db}
c, w := makeGinContext_Boost("POST", "/webhooks/email/999", []byte(`{}`), nil)
c.Params = gin.Params{{Key: "inbox_id", Value: "999"}}
h.HandleEmailWebhook(c)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestEmailWebhookHandler_HandleEmailWebhook_Boost_NilHandler(t *testing.T) {
var h *EmailWebhookHandler
safeCall_Boost(t, func() {
c, _ := makeGinContext_Boost("POST", "/webhooks/email/1", []byte(`{}`), nil)
c.Params = gin.Params{{Key: "inbox_id", Value: "1"}}
h.HandleEmailWebhook(c)
})
}
func TestEmailWebhookHandler_HandleEmailWebhook_Boost_BadBody(t *testing.T) {
db := newBoostTestDB(t)
h := &EmailWebhookHandler{db: db}
c, w := makeGinContext_Boost("POST", "/webhooks/email/1", []byte(`not json`), nil)
c.Params = gin.Params{{Key: "inbox_id", Value: "1"}}
// Will fail inbox lookup since no inbox exists
h.HandleEmailWebhook(c)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestEmailWebhookHandler_parseChannelConfig_Boost(t *testing.T) {
h := &EmailWebhookHandler{}
// Empty config
cfg := h.parseChannelConfig(&model.Inbox{ChannelConfig: ""})
assert.Empty(t, cfg)
// Invalid JSON
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: "not json"})
assert.Empty(t, cfg)
// Valid JSON
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: `{"key":"value"}`})
assert.Equal(t, "value", cfg["key"])
}
// === FacebookWebhookHandler — HandleFacebookWebhook edge cases ===
func TestHandleFacebookWebhook_Boost_SignatureMismatch(t *testing.T) {
db := newBoostTestDB(t)
// Seed an inbox with app_secret
inbox := model.Inbox{
Base: model.Base{ID: 1},
AccountID: 1,
ChannelType: "facebook",
ChannelConfig: `{"app_secret":"mysecret","webhook_verify_token":"tok"}`,
}
db.Create(&inbox)
h := &FacebookWebhookHandler{
webhookParser: fbchannel.NewWebhookParser(),
db: db,
persister: &IncomingPersister{db: db},
}
c, w := makeGinContext_Boost("POST", "/webhooks/facebook/1?page_id=fbpage", []byte(`{"object":"page","entry":[]}`), map[string]string{
"X-Hub-Signature-256": "sha256=invalid",
})
c.Params = gin.Params{{Key: "page_id", Value: "fbpage"}}
h.HandleFacebookWebhook(c)
// Should get forbidden due to signature mismatch
assert.True(t, w.Code == http.StatusForbidden || w.Code == http.StatusOK)
}
func TestHandleFacebookWebhook_Boost_ParseError(t *testing.T) {
db := newBoostTestDB(t)
inbox := model.Inbox{
Base: model.Base{ID: 1},
AccountID: 1,
ChannelType: "facebook",
ChannelConfig: `{"app_secret":"mysecret"}`,
}
db.Create(&inbox)
h := &FacebookWebhookHandler{
webhookParser: fbchannel.NewWebhookParser(),
db: db,
persister: &IncomingPersister{db: db},
}
c, w := makeGinContext_Boost("POST", "/webhooks/facebook/1?page_id=fbpage", []byte(`not-json`), nil)
c.Params = gin.Params{{Key: "page_id", Value: "fbpage"}}
safeCall_Boost(t, func() {
h.HandleFacebookWebhook(c)
})
assert.Equal(t, http.StatusOK, w.Code)
}
// === FacebookWebhookHandler — HandleInstagramWebhook edge cases ===
func TestHandleInstagramWebhook_Boost_NonInstagramObject(t *testing.T) {
h := &FacebookWebhookHandler{
webhookParser: fbchannel.NewWebhookParser(),
}
c, w := makeGinContext_Boost("POST", "/webhooks/instagram", []byte(`{"object":"page","entry":[{"id":"123","time":123}]}`), nil)
safeCall_Boost(t, func() {
h.HandleInstagramWebhook(c)
})
// Should be unprocessable entity or ok
_ = w
}
func TestHandleInstagramWebhook_Boost_SignatureFail(t *testing.T) {
h := &FacebookWebhookHandler{
webhookParser: fbchannel.NewWebhookParser(),
}
c, w := makeGinContext_Boost("POST", "/webhooks/instagram", []byte(`{"object":"instagram","entry":[{"id":"ig-123","time":123,"messaging":[{"sender":{"id":"s1"},"recipient":{"id":"r1"},"timestamp":123,"message":{"mid":"m1","text":"hi"}}]}]}`), map[string]string{
"X-Hub-Signature-256": "sha256=invalid",
})
safeCall_Boost(t, func() {
h.HandleInstagramWebhook(c)
})
// May return 401 or 200 depending on whether parsing succeeds and signature check runs
_ = w
}
// === persistFacebookReceipt ===
func TestPersistFacebookReceipt_Boost_NilPersister(t *testing.T) {
h := &FacebookWebhookHandler{}
// Should not panic with nil persister
safeCall_Boost(t, func() {
h.persistFacebookReceipt(context.Background(), &model.Inbox{}, nil)
})
}
func TestPersistFacebookReceipt_Boost_DeliveryEvent(t *testing.T) {
db := newBoostTestDB(t)
h := &FacebookWebhookHandler{
db: db,
persister: &IncomingPersister{db: db},
}
// Test with nil event — should not panic
safeCall_Boost(t, func() {
h.persistFacebookReceipt(context.Background(), &model.Inbox{Base: model.Base{ID: 1}}, nil)
})
}
// === millisToTime ===
func TestMillisToTime_Boost_Zero(t *testing.T) {
result := millisToTime(0)
assert.Nil(t, result)
}
func TestMillisToTime_Boost_Negative(t *testing.T) {
result := millisToTime(-1)
assert.Nil(t, result)
}
func TestMillisToTime_Boost_Valid(t *testing.T) {
result := millisToTime(1609459200000)
require.NotNil(t, result)
assert.Equal(t, int64(1609459200), result.Unix())
}
// === parseOptionalUintParam ===
func TestParseOptionalUintParam_Boost_Empty(t *testing.T) {
v, err := parseOptionalUintParam("")
require.NoError(t, err)
assert.Equal(t, uint(0), v)
}
func TestParseOptionalUintParam_Boost_Invalid(t *testing.T) {
_, err := parseOptionalUintParam("abc")
assert.Error(t, err)
}
func TestParseOptionalUintParam_Boost_Valid(t *testing.T) {
v, err := parseOptionalUintParam("123")
require.NoError(t, err)
assert.Equal(t, uint(123), v)
}
// === IncomingPersister — performMessageStatusUpdate edge cases ===
func TestPerformMessageStatusUpdate_Boost_NilPersister(t *testing.T) {
var p *IncomingPersister
err := p.performMessageStatusUpdate(context.Background(), nil, "src", model.MessageStatusDelivered, nil, "")
assert.NoError(t, err)
}
func TestPerformMessageStatusUpdate_Boost_NilDB(t *testing.T) {
p := &IncomingPersister{}
err := p.performMessageStatusUpdate(context.Background(), &model.Inbox{Base: model.Base{ID: 1}}, "src", model.MessageStatusDelivered, nil, "")
assert.NoError(t, err)
}
func TestPerformMessageStatusUpdate_Boost_NilInbox(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
err := p.performMessageStatusUpdate(context.Background(), nil, "src", model.MessageStatusDelivered, nil, "")
assert.NoError(t, err)
}
func TestPerformMessageStatusUpdate_Boost_EmptySourceID(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
err := p.performMessageStatusUpdate(context.Background(), &model.Inbox{Base: model.Base{ID: 1}}, "", model.MessageStatusDelivered, nil, "")
assert.NoError(t, err)
}
// === IncomingPersister — performContactMessagesStatusUpdate edge cases ===
func TestPerformContactMessagesStatusUpdate_Boost_NilPersister(t *testing.T) {
var p *IncomingPersister
err := p.performContactMessagesStatusUpdate(context.Background(), nil, "src", model.MessageStatusRead, nil)
assert.NoError(t, err)
}
func TestPerformContactMessagesStatusUpdate_Boost_NilDB(t *testing.T) {
p := &IncomingPersister{}
err := p.performContactMessagesStatusUpdate(context.Background(), &model.Inbox{Base: model.Base{ID: 1}}, "src", model.MessageStatusRead, nil)
assert.NoError(t, err)
}
func TestPerformContactMessagesStatusUpdate_Boost_EmptySourceID(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
err := p.performContactMessagesStatusUpdate(context.Background(), &model.Inbox{Base: model.Base{ID: 1}}, "", model.MessageStatusRead, nil)
assert.NoError(t, err)
}
// === IncomingPersister — performPersistIncoming edge cases ===
func TestPerformPersistIncoming_Boost_NilPersister(t *testing.T) {
var p *IncomingPersister
result, err := p.performPersistIncoming(context.Background(), nil, nil)
assert.NoError(t, err)
assert.Nil(t, result)
}
func TestPerformPersistIncoming_Boost_NilDB(t *testing.T) {
p := &IncomingPersister{}
result, err := p.performPersistIncoming(context.Background(), &model.Inbox{Base: model.Base{ID: 1}}, &channel.IncomingMessage{SourceID: "src"})
assert.NoError(t, err)
assert.Nil(t, result)
}
func TestPerformPersistIncoming_Boost_NilInbox(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
result, err := p.performPersistIncoming(context.Background(), nil, &channel.IncomingMessage{SourceID: "src"})
assert.NoError(t, err)
assert.Nil(t, result)
}
func TestPerformPersistIncoming_Boost_NilMsg(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
result, err := p.performPersistIncoming(context.Background(), &model.Inbox{Base: model.Base{ID: 1}}, nil)
assert.NoError(t, err)
assert.Nil(t, result)
}
// === deliveryStatusContactID edge cases ===
func TestDeliveryStatusContactID_Boost_NilMessage(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
id, err := p.deliveryStatusContactID(context.Background(), p.db, nil)
require.NoError(t, err)
assert.Equal(t, uint(0), id)
}
func TestDeliveryStatusContactID_Boost_NoConversation(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
msg := &model.Message{Base: model.Base{ID: 1}}
id, err := p.deliveryStatusContactID(context.Background(), p.db, msg)
require.NoError(t, err)
assert.Equal(t, uint(0), id)
}
func TestDeliveryStatusContactID_Boost_WithSenderID(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
senderID := uint(42)
msg := &model.Message{Base: model.Base{ID: 1}, SenderID: &senderID}
id, err := p.deliveryStatusContactID(context.Background(), p.db, msg)
require.NoError(t, err)
assert.Equal(t, uint(42), id)
}
// === indexIncomingResult / indexContact / indexConversation / indexMessage with indexer ===
type boostSearchIndexer struct{}
func (b *boostSearchIndexer) IndexContact(ctx context.Context, contact *model.Contact) error {
return nil
}
func (b *boostSearchIndexer) IndexConversation(ctx context.Context, conv *model.Conversation) error {
return nil
}
func (b *boostSearchIndexer) IndexMessage(ctx context.Context, msg *model.Message) error { return nil }
func TestIndexIncomingResult_Boost_WithIndexer(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t), indexer: &boostSearchIndexer{}}
result := &IncomingPersistResult{
Contact: &model.Contact{Base: model.Base{ID: 1}},
Conversation: &model.Conversation{Base: model.Base{ID: 1}},
Message: &model.Message{Base: model.Base{ID: 1}},
}
p.indexIncomingResult(context.Background(), result)
}
func TestIndexIncomingResult_Boost_Duplicate(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t), indexer: &boostSearchIndexer{}}
result := &IncomingPersistResult{
Duplicate: true,
}
p.indexIncomingResult(context.Background(), result)
}
func TestIndexContact_Boost_NilIndexer(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
p.indexContact(context.Background(), &model.Contact{})
}
func TestIndexConversation_Boost_NilIndexer(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
p.indexConversation(context.Background(), &model.Conversation{})
}
func TestIndexMessage_Boost_NilIndexer(t *testing.T) {
p := &IncomingPersister{db: newBoostTestDB(t)}
p.indexMessage(context.Background(), &model.Message{})
}
// === validInstagramVerifyToken ===
func TestValidInstagramVerifyToken_Boost_EnvToken(t *testing.T) {
os.Setenv("IG_VERIFY_TOKEN", "ig-token-123")
defer os.Unsetenv("IG_VERIFY_TOKEN")
h := &FacebookWebhookHandler{}
assert.True(t, h.validInstagramVerifyToken("ig-token-123"))
assert.False(t, h.validInstagramVerifyToken("wrong"))
assert.False(t, h.validInstagramVerifyToken(""))
}
func TestValidInstagramVerifyToken_Boost_InstagramToken(t *testing.T) {
os.Unsetenv("IG_VERIFY_TOKEN")
os.Setenv("INSTAGRAM_VERIFY_TOKEN", "ig-token-456")
defer os.Unsetenv("INSTAGRAM_VERIFY_TOKEN")
h := &FacebookWebhookHandler{}
assert.True(t, h.validInstagramVerifyToken("ig-token-456"))
}
// === verifyInstagramSignature ===
func TestVerifyInstagramSignature_Boost_NoHeader(t *testing.T) {
h := &FacebookWebhookHandler{}
c, _ := makeGinContext_Boost("POST", "/webhooks/instagram", []byte(`{}`), nil)
err := h.verifyInstagramSignature(c, []byte(`{}`), nil)
assert.Error(t, err)
}
// === lookupInstagramInboxForEvent ===
func TestLookupInstagramInboxForEvent_Boost_NilDB(t *testing.T) {
h := &FacebookWebhookHandler{}
safeCall_Boost(t, func() {
_, _ = h.lookupInstagramInboxForEvent(nil)
})
}
// === resolveVerifyToken / resolveAppSecret ===
func TestResolveVerifyToken_Boost(t *testing.T) {
h := &FacebookWebhookHandler{}
// Empty config
assert.Empty(t, h.resolveVerifyToken(&model.Inbox{ChannelConfig: ""}))
// Invalid JSON
assert.Empty(t, h.resolveVerifyToken(&model.Inbox{ChannelConfig: "not json"}))
// With token
assert.Equal(t, "tok123", h.resolveVerifyToken(&model.Inbox{ChannelConfig: `{"webhook_verify_token":"tok123"}`}))
}
func TestResolveAppSecret_Boost(t *testing.T) {
h := &FacebookWebhookHandler{}
// Empty config
assert.Empty(t, h.resolveAppSecret(&model.Inbox{ChannelConfig: ""}))
// Invalid JSON
assert.Empty(t, h.resolveAppSecret(&model.Inbox{ChannelConfig: "not json"}))
// With secret
assert.Equal(t, "secret123", h.resolveAppSecret(&model.Inbox{ChannelConfig: `{"app_secret":"secret123"}`}))
}
func TestFB_parseChannelConfig_Boost(t *testing.T) {
h := &FacebookWebhookHandler{}
// Empty
cfg := h.parseChannelConfig(&model.Inbox{ChannelConfig: ""})
assert.Empty(t, cfg)
// Invalid JSON
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: "not json"})
assert.Empty(t, cfg)
// Valid
cfg = h.parseChannelConfig(&model.Inbox{ChannelConfig: `{"key":"val"}`})
assert.Equal(t, "val", cfg["key"])
}
// === performPersistIncoming with real DB — duplicate detection ===
func TestPerformPersistIncoming_Boost_DuplicateMessage(t *testing.T) {
db := newBoostTestDB(t)
p := &IncomingPersister{db: db}
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1, ChannelType: "telegram"}
db.Create(inbox)
msg := &channel.IncomingMessage{
ChannelType: channel.ChannelTelegram,
SourceID: "dup-src-1",
SenderID: "sender-1",
SenderName: "Test",
SenderType: channel.SenderContact,
Content: "Hello",
ContentType: channel.ContentText,
InboxID: 1,
AccountID: 1,
}
// First persist
result1, err := p.performPersistIncoming(context.Background(), inbox, msg)
require.NoError(t, err)
require.NotNil(t, result1)
assert.False(t, result1.Duplicate)
// Second persist — should be duplicate
result2, err := p.performPersistIncoming(context.Background(), inbox, msg)
require.NoError(t, err)
require.NotNil(t, result2)
assert.True(t, result2.Duplicate)
}
// === performMessageStatusUpdate with real DB ===
func TestPerformMessageStatusUpdate_Boost_MessageNotFound(t *testing.T) {
db := newBoostTestDB(t)
p := &IncomingPersister{db: db}
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
db.Create(inbox)
now := time.Now()
err := p.performMessageStatusUpdate(context.Background(), inbox, "nonexistent-src", model.MessageStatusDelivered, &now, "")
assert.NoError(t, err) // message not found → returns nil
}
// === performContactMessagesStatusUpdate with real DB ===
func TestPerformContactMessagesStatusUpdate_Boost_ContactInboxNotFound(t *testing.T) {
db := newBoostTestDB(t)
p := &IncomingPersister{db: db}
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
db.Create(inbox)
now := time.Now()
err := p.performContactMessagesStatusUpdate(context.Background(), inbox, "nonexistent-contact", model.MessageStatusRead, &now)
assert.NoError(t, err)
}
// === upsertDeliveryStatus edge cases ===
func TestUpsertDeliveryStatus_Boost_NewDelivery(t *testing.T) {
db := newBoostTestDB(t)
p := &IncomingPersister{db: db}
inbox := &model.Inbox{Base: model.Base{ID: 1}, AccountID: 1}
db.Create(inbox)
contact := &model.Contact{Base: model.Base{ID: 1}, AccountID: 1, Name: "Test", Identifier: "test-id"}
db.Create(contact)
conversation := &model.Conversation{Base: model.Base{ID: 1}, InboxID: 1, AccountID: 1, ContactID: 1, Status: "open"}
db.Create(conversation)
msg := &model.Message{
Base: model.Base{ID: 1},
InboxID: 1,
ConversationID: 1,
Content: "Test",
ContentType: "text",
Status: string(model.MessageStatusSent),
ContentAttributes: datatypes.JSON([]byte(`{}`)),
}
db.Create(msg)
now := time.Now()
err := p.upsertDeliveryStatus(context.Background(), db, msg, 1, model.MessageStatusDelivered, &now)
require.NoError(t, err)
// Update existing delivery
err = p.upsertDeliveryStatus(context.Background(), db, msg, 1, model.MessageStatusRead, &now)
require.NoError(t, err)
}