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