Align GoChat with Chatwoot frontend contracts
This commit is contained in:
@@ -69,6 +69,33 @@ func (l *recordingListener) OnEvent(ctx context.Context, event *channel.ChannelE
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestValidProviderMessageStatusTransition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current model.MessageStatus
|
||||
next model.MessageStatus
|
||||
want bool
|
||||
}{
|
||||
{name: "sent to delivered", current: model.MessageStatusSent, next: model.MessageStatusDelivered, want: true},
|
||||
{name: "delivered to read", current: model.MessageStatusDelivered, next: model.MessageStatusRead, want: true},
|
||||
{name: "sent to failed", current: model.MessageStatusSent, next: model.MessageStatusFailed, want: true},
|
||||
{name: "failed can be recovered by provider", current: model.MessageStatusFailed, next: model.MessageStatusDelivered, want: true},
|
||||
{name: "duplicate delivered is noop", current: model.MessageStatusDelivered, next: model.MessageStatusDelivered, want: false},
|
||||
{name: "duplicate failed is noop", current: model.MessageStatusFailed, next: model.MessageStatusFailed, want: false},
|
||||
{name: "read does not regress to delivered", current: model.MessageStatusRead, next: model.MessageStatusDelivered, want: false},
|
||||
{name: "delivered does not regress to sent", current: model.MessageStatusDelivered, next: model.MessageStatusSent, want: false},
|
||||
{name: "invalid next is rejected", current: model.MessageStatusSent, next: model.MessageStatus("queued"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := validProviderMessageStatusTransition(tt.current, tt.next); got != tt.want {
|
||||
t.Fatalf("validProviderMessageStatusTransition(%q, %q) = %v, want %v", tt.current, tt.next, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newWebhookLookupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
@@ -92,6 +119,7 @@ func newWebhookLookupTestDB(t *testing.T) *gorm.DB {
|
||||
&channelmodel.ChannelTwilioSMS{},
|
||||
&channelmodel.ChannelWhatsApp{},
|
||||
&channelmodel.ChannelTikTok{},
|
||||
&channelmodel.ChannelFacebook{},
|
||||
&channelmodel.ChannelInstagram{},
|
||||
&model.IntegrationHook{},
|
||||
); err != nil {
|
||||
@@ -190,6 +218,93 @@ func TestIncomingPersisterUpdatesMessageStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwilioDeliveryStatusWebhookPersistsFailedStatusAndExternalError(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: "ACtwilio",
|
||||
PhoneNumber: "+15551234567",
|
||||
MessagingServiceSID: "MGtwilio",
|
||||
}
|
||||
if err := db.Create(&twilioChannel).Error; err != nil {
|
||||
t.Fatalf("create twilio channel: %v", err)
|
||||
}
|
||||
inbox.ChannelID = twilioChannel.ID
|
||||
if err := db.Save(&inbox).Error; err != nil {
|
||||
t.Fatalf("update inbox channel id: %v", err)
|
||||
}
|
||||
contact := model.Contact{AccountID: inbox.AccountID, Name: "Twilio Contact"}
|
||||
if err := db.Create(&contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
message := model.Message{
|
||||
AccountID: inbox.AccountID,
|
||||
InboxID: inbox.ID,
|
||||
ConversationID: conversation.ID,
|
||||
SenderType: "agent",
|
||||
Content: "outbound sms",
|
||||
ContentType: "text",
|
||||
Status: string(model.MessageStatusSent),
|
||||
MessageType: string(model.MessageTypeOutgoing),
|
||||
SourceID: "SMtwilio",
|
||||
}
|
||||
if err := db.Create(&message).Error; err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
dispatcher := channel.NewDispatcher()
|
||||
listener := &recordingListener{}
|
||||
dispatcher.Register(listener)
|
||||
handler := NewTwilioWebhookHandler(nil, db, dispatcher)
|
||||
router := gin.New()
|
||||
router.POST("/webhooks/twilio/status/:phone_number", handler.HandleTwilioDeliveryStatus)
|
||||
|
||||
form := url.Values{
|
||||
"MessageSid": {"SMtwilio"},
|
||||
"MessageStatus": {"undelivered"},
|
||||
"ErrorCode": {"30007"},
|
||||
"ErrorMessage": {"Carrier violation"},
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/webhooks/twilio/status/15551234567", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected Twilio no-content ack, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var updated model.Message
|
||||
if err := db.First(&updated, message.ID).Error; err != nil {
|
||||
t.Fatalf("load updated message: %v", err)
|
||||
}
|
||||
if updated.Status != string(model.MessageStatusFailed) {
|
||||
t.Fatalf("expected failed message status, got %s", updated.Status)
|
||||
}
|
||||
attrs := map[string]any{}
|
||||
if err := json.Unmarshal(updated.ContentAttributes, &attrs); err != nil {
|
||||
t.Fatalf("invalid content attributes: %v", err)
|
||||
}
|
||||
if attrs["external_error"] != "30007 - Carrier violation" {
|
||||
t.Fatalf("expected Twilio external error, got %#v", attrs)
|
||||
}
|
||||
var delivery model.DeliveryStatus
|
||||
if err := db.Where("message_id = ? AND contact_id = ?", updated.ID, contact.ID).First(&delivery).Error; err != nil {
|
||||
t.Fatalf("expected delivery status: %v", err)
|
||||
}
|
||||
if delivery.Status != model.MessageStatusFailed {
|
||||
t.Fatalf("expected failed delivery status, got %#v", delivery)
|
||||
}
|
||||
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
||||
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingPersisterQueuesMessageStatusUpdateWithWorker(t *testing.T) {
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "telegram")
|
||||
@@ -638,6 +753,40 @@ func seedWebhookInbox(t *testing.T, db *gorm.DB, channelType string) model.Inbox
|
||||
return inbox
|
||||
}
|
||||
|
||||
func seedInstagramReceiptConversation(t *testing.T, db *gorm.DB, instagramAccountID, contactSourceID string) (model.Inbox, model.Contact, model.Conversation) {
|
||||
t.Helper()
|
||||
|
||||
inbox := seedWebhookInbox(t, db, "instagram")
|
||||
instagramChannel := channelmodel.ChannelInstagram{
|
||||
AccountID: inbox.AccountID,
|
||||
InboxID: inbox.ID,
|
||||
InstagramAccountID: instagramAccountID,
|
||||
PageAccessToken: "page-token",
|
||||
ConnectedFBPageID: "fb-page-for-" + instagramAccountID,
|
||||
InstagramAccountName: "support_ig",
|
||||
}
|
||||
if err := db.Create(&instagramChannel).Error; err != nil {
|
||||
t.Fatalf("create instagram channel: %v", err)
|
||||
}
|
||||
inbox.ChannelID = instagramChannel.ID
|
||||
if err := db.Save(&inbox).Error; err != nil {
|
||||
t.Fatalf("update instagram inbox channel id: %v", err)
|
||||
}
|
||||
contact := model.Contact{AccountID: inbox.AccountID, Name: "Instagram Contact"}
|
||||
if err := db.Create(&contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: contactSourceID, PubsubToken: "pub-" + contactSourceID}
|
||||
if err := db.Create(&contactInbox).Error; err != nil {
|
||||
t.Fatalf("create contact inbox: %v", err)
|
||||
}
|
||||
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
return inbox, contact, conversation
|
||||
}
|
||||
|
||||
func TestTelegramWebhookLookupInboxByBotToken(t *testing.T) {
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "telegram")
|
||||
@@ -697,6 +846,279 @@ func TestTelegramWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacebookWebhookDeliveryReceiptPersistsDeliveredStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "facebook")
|
||||
facebookChannel := channelmodel.ChannelFacebook{
|
||||
AccountID: inbox.AccountID,
|
||||
InboxID: inbox.ID,
|
||||
PageID: "page-123",
|
||||
PageAccessToken: "page-token",
|
||||
PageName: "Support Page",
|
||||
}
|
||||
if err := db.Create(&facebookChannel).Error; err != nil {
|
||||
t.Fatalf("create facebook channel: %v", err)
|
||||
}
|
||||
inbox.ChannelID = facebookChannel.ID
|
||||
if err := db.Save(&inbox).Error; err != nil {
|
||||
t.Fatalf("update inbox channel id: %v", err)
|
||||
}
|
||||
contact := model.Contact{AccountID: inbox.AccountID, Name: "Facebook Contact"}
|
||||
if err := db.Create(&contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "fb-user-1", PubsubToken: "pub-fb"}
|
||||
if err := db.Create(&contactInbox).Error; err != nil {
|
||||
t.Fatalf("create contact inbox: %v", err)
|
||||
}
|
||||
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
message := createWebhookStatusMessage(t, db, inbox, conversation, "fb-out-1", model.MessageStatusSent, time.Now().Add(-time.Minute).UTC())
|
||||
|
||||
dispatcher := channel.NewDispatcher()
|
||||
listener := &recordingListener{}
|
||||
dispatcher.Register(listener)
|
||||
handler := NewFacebookWebhookHandler(nil, nil, db, dispatcher)
|
||||
router := gin.New()
|
||||
router.POST("/webhooks/facebook/:page_id", handler.HandleFacebookWebhook)
|
||||
watermark := time.Now().UTC().UnixMilli()
|
||||
body := []byte(`{"object":"page","entry":[{"id":"page-123","time":` + strconv.FormatInt(watermark, 10) + `,"messaging":[{"sender":{"id":"fb-user-1"},"recipient":{"id":"page-123"},"timestamp":` + strconv.FormatInt(watermark, 10) + `,"delivery":{"mids":["fb-out-1"],"watermark":` + strconv.FormatInt(watermark, 10) + `}}]}]}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/facebook/page-123", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected Facebook ack, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
assertWebhookMessageStatus(t, db, message.ID, model.MessageStatusDelivered)
|
||||
var delivery model.DeliveryStatus
|
||||
if err := db.Where("message_id = ? AND contact_id = ?", message.ID, contact.ID).First(&delivery).Error; err != nil {
|
||||
t.Fatalf("expected delivery status: %v", err)
|
||||
}
|
||||
if delivery.Status != model.MessageStatusDelivered {
|
||||
t.Fatalf("expected delivered delivery status, got %s", delivery.Status)
|
||||
}
|
||||
if delivery.DeliveredAt == nil {
|
||||
t.Fatalf("expected delivered_at timestamp")
|
||||
}
|
||||
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
||||
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
||||
}
|
||||
retry := httptest.NewRecorder()
|
||||
retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/facebook/page-123", bytes.NewReader(body))
|
||||
retryReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(retry, retryReq)
|
||||
if retry.Code != http.StatusOK {
|
||||
t.Fatalf("expected duplicate Facebook ack, got %d body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
var deliveryCount int64
|
||||
if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", message.ID, contact.ID).Count(&deliveryCount).Error; err != nil {
|
||||
t.Fatalf("count delivery statuses: %v", err)
|
||||
}
|
||||
if deliveryCount != 1 {
|
||||
t.Fatalf("expected duplicate delivery receipt to keep one delivery status row, got %d", deliveryCount)
|
||||
}
|
||||
if len(listener.events) != 1 {
|
||||
t.Fatalf("expected duplicate delivery receipt not to dispatch another status event, got %#v", listener.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacebookWebhookReadReceiptPersistsReadStatusForContactConversation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "facebook")
|
||||
facebookChannel := channelmodel.ChannelFacebook{
|
||||
AccountID: inbox.AccountID,
|
||||
InboxID: inbox.ID,
|
||||
PageID: "page-read-123",
|
||||
PageAccessToken: "page-token",
|
||||
PageName: "Support Page",
|
||||
}
|
||||
if err := db.Create(&facebookChannel).Error; err != nil {
|
||||
t.Fatalf("create facebook channel: %v", err)
|
||||
}
|
||||
inbox.ChannelID = facebookChannel.ID
|
||||
if err := db.Save(&inbox).Error; err != nil {
|
||||
t.Fatalf("update inbox channel id: %v", err)
|
||||
}
|
||||
contact := model.Contact{AccountID: inbox.AccountID, Name: "Facebook Read Contact"}
|
||||
if err := db.Create(&contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "fb-user-read", PubsubToken: "pub-fb-read"}
|
||||
if err := db.Create(&contactInbox).Error; err != nil {
|
||||
t.Fatalf("create contact inbox: %v", err)
|
||||
}
|
||||
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
cutoff := time.Now().UTC()
|
||||
beforeSent := createWebhookStatusMessage(t, db, inbox, conversation, "fb-read-sent", model.MessageStatusSent, cutoff.Add(-time.Minute))
|
||||
beforeDelivered := createWebhookStatusMessage(t, db, inbox, conversation, "fb-read-delivered", model.MessageStatusDelivered, cutoff.Add(-30*time.Second))
|
||||
|
||||
dispatcher := channel.NewDispatcher()
|
||||
listener := &recordingListener{}
|
||||
dispatcher.Register(listener)
|
||||
handler := NewFacebookWebhookHandler(nil, nil, db, dispatcher)
|
||||
router := gin.New()
|
||||
router.POST("/webhooks/facebook/:page_id", handler.HandleFacebookWebhook)
|
||||
watermark := cutoff.UnixMilli()
|
||||
body := []byte(`{"object":"page","entry":[{"id":"page-read-123","time":` + strconv.FormatInt(watermark, 10) + `,"messaging":[{"sender":{"id":"fb-user-read"},"recipient":{"id":"page-read-123"},"timestamp":` + strconv.FormatInt(watermark, 10) + `,"read":{"watermark":` + strconv.FormatInt(watermark, 10) + `}}]}]}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/facebook/page-read-123", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected Facebook ack, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
assertWebhookMessageStatus(t, db, beforeSent.ID, model.MessageStatusRead)
|
||||
assertWebhookMessageStatus(t, db, beforeDelivered.ID, model.MessageStatusRead)
|
||||
var readCount int64
|
||||
if err := db.Model(&model.DeliveryStatus{}).Where("contact_id = ? AND status = ?", contact.ID, model.MessageStatusRead).Count(&readCount).Error; err != nil {
|
||||
t.Fatalf("count read delivery statuses: %v", err)
|
||||
}
|
||||
if readCount != 2 {
|
||||
t.Fatalf("expected 2 read delivery statuses, got %d", readCount)
|
||||
}
|
||||
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
||||
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
||||
}
|
||||
retry := httptest.NewRecorder()
|
||||
retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/facebook/page-read-123", bytes.NewReader(body))
|
||||
retryReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(retry, retryReq)
|
||||
if retry.Code != http.StatusOK {
|
||||
t.Fatalf("expected duplicate Facebook read ack, got %d body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
if err := db.Model(&model.DeliveryStatus{}).Where("contact_id = ? AND status = ?", contact.ID, model.MessageStatusRead).Count(&readCount).Error; err != nil {
|
||||
t.Fatalf("count read delivery statuses after retry: %v", err)
|
||||
}
|
||||
if readCount != 2 {
|
||||
t.Fatalf("expected duplicate read receipt to keep two read delivery statuses, got %d", readCount)
|
||||
}
|
||||
if len(listener.events) != 2 {
|
||||
t.Fatalf("expected duplicate read receipt not to dispatch more status events, got %#v", listener.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstagramWebhookDeliveryReceiptPersistsDeliveredStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("INSTAGRAM_APP_SECRET", "ig-secret")
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox, contact, conversation := seedInstagramReceiptConversation(t, db, "ig-account-1", "ig-user-1")
|
||||
message := createWebhookStatusMessage(t, db, inbox, conversation, "ig-out-1", model.MessageStatusSent, time.Now().Add(-time.Minute).UTC())
|
||||
|
||||
dispatcher := channel.NewDispatcher()
|
||||
listener := &recordingListener{}
|
||||
dispatcher.Register(listener)
|
||||
handler := NewFacebookWebhookHandler(nil, nil, db, dispatcher)
|
||||
router := gin.New()
|
||||
router.POST("/webhooks/instagram", handler.HandleInstagramWebhook)
|
||||
watermark := time.Now().UTC().UnixMilli()
|
||||
body := []byte(`{"object":"instagram","entry":[{"id":"ig-account-1","time":` + strconv.FormatInt(watermark, 10) + `,"messaging":[{"sender":{"id":"ig-user-1"},"recipient":{"id":"ig-account-1"},"timestamp":` + strconv.FormatInt(watermark, 10) + `,"delivery":{"mids":["ig-out-1"],"watermark":` + strconv.FormatInt(watermark, 10) + `}}]}]}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body))
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected Instagram ack, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
assertWebhookMessageStatus(t, db, message.ID, model.MessageStatusDelivered)
|
||||
var delivery model.DeliveryStatus
|
||||
if err := db.Where("message_id = ? AND contact_id = ?", message.ID, contact.ID).First(&delivery).Error; err != nil {
|
||||
t.Fatalf("expected delivery status: %v", err)
|
||||
}
|
||||
if delivery.Status != model.MessageStatusDelivered || delivery.DeliveredAt == nil {
|
||||
t.Fatalf("expected delivered status with timestamp, got %#v", delivery)
|
||||
}
|
||||
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
||||
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
||||
}
|
||||
retry := httptest.NewRecorder()
|
||||
retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body))
|
||||
retryReq.Header.Set("Content-Type", "application/json")
|
||||
retryReq.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body))
|
||||
router.ServeHTTP(retry, retryReq)
|
||||
if retry.Code != http.StatusOK {
|
||||
t.Fatalf("expected duplicate Instagram ack, got %d body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
var deliveryCount int64
|
||||
if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", message.ID, contact.ID).Count(&deliveryCount).Error; err != nil {
|
||||
t.Fatalf("count delivery statuses: %v", err)
|
||||
}
|
||||
if deliveryCount != 1 {
|
||||
t.Fatalf("expected duplicate delivery receipt to keep one delivery status row, got %d", deliveryCount)
|
||||
}
|
||||
if len(listener.events) != 1 {
|
||||
t.Fatalf("expected duplicate delivery receipt not to dispatch another status event, got %#v", listener.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstagramWebhookReadReceiptPersistsReadStatusForContactConversation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("INSTAGRAM_APP_SECRET", "ig-secret")
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox, contact, conversation := seedInstagramReceiptConversation(t, db, "ig-read-account", "ig-read-user")
|
||||
cutoff := time.Now().UTC()
|
||||
beforeSent := createWebhookStatusMessage(t, db, inbox, conversation, "ig-read-sent", model.MessageStatusSent, cutoff.Add(-time.Minute))
|
||||
beforeDelivered := createWebhookStatusMessage(t, db, inbox, conversation, "ig-read-delivered", model.MessageStatusDelivered, cutoff.Add(-30*time.Second))
|
||||
|
||||
dispatcher := channel.NewDispatcher()
|
||||
listener := &recordingListener{}
|
||||
dispatcher.Register(listener)
|
||||
handler := NewFacebookWebhookHandler(nil, nil, db, dispatcher)
|
||||
router := gin.New()
|
||||
router.POST("/webhooks/instagram", handler.HandleInstagramWebhook)
|
||||
watermark := cutoff.UnixMilli()
|
||||
body := []byte(`{"object":"instagram","entry":[{"id":"ig-read-account","time":` + strconv.FormatInt(watermark, 10) + `,"messaging":[{"sender":{"id":"ig-read-user"},"recipient":{"id":"ig-read-account"},"timestamp":` + strconv.FormatInt(watermark, 10) + `,"read":{"watermark":` + strconv.FormatInt(watermark, 10) + `}}]}]}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body))
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected Instagram ack, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
assertWebhookMessageStatus(t, db, beforeSent.ID, model.MessageStatusRead)
|
||||
assertWebhookMessageStatus(t, db, beforeDelivered.ID, model.MessageStatusRead)
|
||||
var readCount int64
|
||||
if err := db.Model(&model.DeliveryStatus{}).Where("contact_id = ? AND status = ?", contact.ID, model.MessageStatusRead).Count(&readCount).Error; err != nil {
|
||||
t.Fatalf("count read delivery statuses: %v", err)
|
||||
}
|
||||
if readCount != 2 {
|
||||
t.Fatalf("expected 2 read delivery statuses, got %d", readCount)
|
||||
}
|
||||
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
||||
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
||||
}
|
||||
retry := httptest.NewRecorder()
|
||||
retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body))
|
||||
retryReq.Header.Set("Content-Type", "application/json")
|
||||
retryReq.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body))
|
||||
router.ServeHTTP(retry, retryReq)
|
||||
if retry.Code != http.StatusOK {
|
||||
t.Fatalf("expected duplicate Instagram read ack, got %d body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
if err := db.Model(&model.DeliveryStatus{}).Where("contact_id = ? AND status = ?", contact.ID, model.MessageStatusRead).Count(&readCount).Error; err != nil {
|
||||
t.Fatalf("count read delivery statuses after retry: %v", err)
|
||||
}
|
||||
if readCount != 2 {
|
||||
t.Fatalf("expected duplicate read receipt to keep two read delivery statuses, got %d", readCount)
|
||||
}
|
||||
if len(listener.events) != 2 {
|
||||
t.Fatalf("expected duplicate read receipt not to dispatch more status events, got %#v", listener.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineWebhookLookupInboxByLineChannelID(t *testing.T) {
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "line")
|
||||
@@ -791,6 +1213,45 @@ func TestLineWebhookRejectsMissingSignatureWhenSecretConfigured(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineWebhookRejectsInvalidSignatureWhenSecretConfigured(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "line")
|
||||
inbox.ChannelConfig = `{"channel_secret":"line-secret"}`
|
||||
if err := db.Save(&inbox).Error; err != nil {
|
||||
t.Fatalf("update line inbox config: %v", err)
|
||||
}
|
||||
channelRecord := channelmodel.ChannelLINE{AccountID: 1, InboxID: inbox.ID, ChannelID: "line-channel-1", Name: "LINE OA"}
|
||||
if err := db.Create(&channelRecord).Error; err != nil {
|
||||
t.Fatalf("create line channel: %v", err)
|
||||
}
|
||||
|
||||
lineRepo := linechannel.NewRepository(db)
|
||||
lineService := linechannel.NewLineService(lineRepo)
|
||||
linePipeline := linechannel.NewIncomingProcessor(lineService)
|
||||
h := NewLineWebhookHandler(nil, linePipeline, lineService, db)
|
||||
r := gin.New()
|
||||
r.POST("/webhooks/line/:line_channel_id", h.HandleLineWebhook)
|
||||
body := []byte(`{"destination":"line-channel-1","events":[{"type":"message","replyToken":"reply-1","timestamp":1710000000000,"source":{"type":"user","userId":"line-user-1"},"message":{"type":"text","id":"line-msg-bad-sig","text":"hello line"}}]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/line/line-channel-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Line-Signature", lineSignature("wrong-secret", body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Message{}).Where("inbox_id = ? AND source_id = ?", inbox.ID, "line-msg-bad-sig").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count message: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected no persisted message, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwilioWebhookLookupInboxByPhoneNumber(t *testing.T) {
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
||||
@@ -966,7 +1427,7 @@ func TestTwilioInboundSMSQueuesIncomingMessageWithWorker(t *testing.T) {
|
||||
assertPersistedMessage(t, db, inbox.ID, "SMINASYNC1", "queued sms")
|
||||
}
|
||||
|
||||
func TestTwilioDeliveryStatusUpdatesExistingMessage(t *testing.T) {
|
||||
func TestTwilioDeliveryStatusPersistsDeliveredAndReadStatuses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
||||
@@ -986,29 +1447,71 @@ func TestTwilioDeliveryStatusUpdatesExistingMessage(t *testing.T) {
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "out", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "SM123"}
|
||||
if err := db.Create(&message).Error; err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
deliveredMessage := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "delivered", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "SMDELIVERED"}
|
||||
readMessage := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "read", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusDelivered), SourceID: "SMREAD"}
|
||||
if err := db.Create(&deliveredMessage).Error; err != nil {
|
||||
t.Fatalf("create delivered message: %v", err)
|
||||
}
|
||||
if err := db.Create(&readMessage).Error; err != nil {
|
||||
t.Fatalf("create read message: %v", err)
|
||||
}
|
||||
|
||||
h := NewTwilioWebhookHandler(nil, db)
|
||||
dispatcher := channel.NewDispatcher()
|
||||
listener := &recordingListener{}
|
||||
dispatcher.Register(listener)
|
||||
h := NewTwilioWebhookHandler(nil, db, dispatcher)
|
||||
r := gin.New()
|
||||
r.POST("/webhooks/twilio/status/:phone_number", h.HandleTwilioDeliveryStatus)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/twilio/status/+15551234567", strings.NewReader("MessageSid=SM123&MessageStatus=delivered"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
for _, form := range []string{
|
||||
"MessageSid=SMDELIVERED&MessageStatus=delivered",
|
||||
"MessageSid=SMREAD&MessageStatus=read",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/twilio/status/+15551234567", strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", w.Code)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
var updated model.Message
|
||||
if err := db.First(&updated, message.ID).Error; err != nil {
|
||||
t.Fatalf("load message: %v", err)
|
||||
assertWebhookMessageStatus(t, db, deliveredMessage.ID, model.MessageStatusDelivered)
|
||||
assertWebhookMessageStatus(t, db, readMessage.ID, model.MessageStatusRead)
|
||||
var delivered model.DeliveryStatus
|
||||
if err := db.Where("message_id = ? AND contact_id = ?", deliveredMessage.ID, contact.ID).First(&delivered).Error; err != nil {
|
||||
t.Fatalf("expected delivered delivery status: %v", err)
|
||||
}
|
||||
if updated.Status != string(model.MessageStatusDelivered) {
|
||||
t.Fatalf("expected delivered, got %s", updated.Status)
|
||||
if delivered.Status != model.MessageStatusDelivered {
|
||||
t.Fatalf("expected delivered delivery status, got %#v", delivered)
|
||||
}
|
||||
var read model.DeliveryStatus
|
||||
if err := db.Where("message_id = ? AND contact_id = ?", readMessage.ID, contact.ID).First(&read).Error; err != nil {
|
||||
t.Fatalf("expected read delivery status: %v", err)
|
||||
}
|
||||
if read.Status != model.MessageStatusRead {
|
||||
t.Fatalf("expected read delivery status, got %#v", read)
|
||||
}
|
||||
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
||||
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
||||
}
|
||||
|
||||
duplicate := httptest.NewRecorder()
|
||||
duplicateReq := httptest.NewRequest(http.MethodPost, "/webhooks/twilio/status/+15551234567", strings.NewReader("MessageSid=SMDELIVERED&MessageStatus=delivered"))
|
||||
duplicateReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.ServeHTTP(duplicate, duplicateReq)
|
||||
if duplicate.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected duplicate callback 204, got %d", duplicate.Code)
|
||||
}
|
||||
var deliveredCount int64
|
||||
if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", deliveredMessage.ID, contact.ID).Count(&deliveredCount).Error; err != nil {
|
||||
t.Fatalf("count delivered status rows: %v", err)
|
||||
}
|
||||
if deliveredCount != 1 {
|
||||
t.Fatalf("expected duplicate callback to keep one delivery status row, got %d", deliveredCount)
|
||||
}
|
||||
if len(listener.events) != 2 {
|
||||
t.Fatalf("expected duplicate callback not to dispatch another status event, got %#v", listener.events)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,6 +1616,157 @@ func TestWhatsAppWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
assertPersistedMessage(t, db, inbox.ID, "wamid-1", "hello whatsapp")
|
||||
}
|
||||
|
||||
func TestWhatsAppWebhookPersistsDeliveryStatusAndFailureError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "whatsapp")
|
||||
waChannel := channelmodel.ChannelWhatsApp{AccountID: inbox.AccountID, InboxID: inbox.ID, PhoneNumber: "+15551230000", PhoneNumberID: "phone-id-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"app_secret":"wa-secret"}`, WebhookVerifyToken: "verify-token"}
|
||||
if err := db.Create(&waChannel).Error; err != nil {
|
||||
t.Fatalf("create whatsapp channel: %v", err)
|
||||
}
|
||||
contact := model.Contact{AccountID: inbox.AccountID, Name: "WhatsApp Contact", Identifier: "15550001111"}
|
||||
if err := db.Create(&contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "out", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "wamid-out-1"}
|
||||
if err := db.Create(&message).Error; err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
|
||||
waRepo := whatsappchannel.NewRepository(db)
|
||||
waService := whatsappchannel.NewWhatsAppService(waRepo)
|
||||
waPipeline := whatsappchannel.NewIncomingPipeline(waService)
|
||||
waProvider := whatsappchannel.NewWhatsAppProvider(waService, waRepo, waPipeline)
|
||||
waWebhook := whatsappchannel.NewWebhookHandler(waProvider)
|
||||
h := NewWhatsAppWebhookHandler(waProvider, waWebhook, db)
|
||||
r := gin.New()
|
||||
r.POST("/webhooks/whatsapp/:phone_number", h.HandleWhatsAppWebhook)
|
||||
body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"waba-1","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"+15551230000","phone_number_id":"phone-id-1"},"statuses":[{"id":"wamid-out-1","status":"failed","timestamp":"1710000100","recipient_id":"15550001111","errors":[{"code":131047,"title":"Re-engagement message","message":"Message failed"}]}]}}]}]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/+15551230000", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Hub-Signature-256", metaSignature("wa-secret", body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var updated model.Message
|
||||
if err := db.First(&updated, message.ID).Error; err != nil {
|
||||
t.Fatalf("load updated message: %v", err)
|
||||
}
|
||||
if updated.Status != string(model.MessageStatusFailed) {
|
||||
t.Fatalf("expected failed status, got %s", updated.Status)
|
||||
}
|
||||
attrs := map[string]any{}
|
||||
if err := json.Unmarshal(updated.ContentAttributes, &attrs); err != nil {
|
||||
t.Fatalf("unmarshal attrs: %v", err)
|
||||
}
|
||||
if attrs["external_error"] != "131047 - Re-engagement message" {
|
||||
t.Fatalf("expected whatsapp external error, got %#v", attrs)
|
||||
}
|
||||
var delivery model.DeliveryStatus
|
||||
if err := db.Where("message_id = ? AND contact_id = ?", updated.ID, contact.ID).First(&delivery).Error; err != nil {
|
||||
t.Fatalf("expected delivery status: %v", err)
|
||||
}
|
||||
if delivery.Status != model.MessageStatusFailed {
|
||||
t.Fatalf("expected delivery failed, got %s", delivery.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatsAppWebhookPersistsDeliveredAndReadStatuses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "whatsapp")
|
||||
waChannel := channelmodel.ChannelWhatsApp{AccountID: inbox.AccountID, InboxID: inbox.ID, PhoneNumber: "+15551230000", PhoneNumberID: "phone-id-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"app_secret":"wa-secret"}`, WebhookVerifyToken: "verify-token"}
|
||||
if err := db.Create(&waChannel).Error; err != nil {
|
||||
t.Fatalf("create whatsapp channel: %v", err)
|
||||
}
|
||||
contact := model.Contact{AccountID: inbox.AccountID, Name: "WhatsApp Status Contact", Identifier: "15550002222"}
|
||||
if err := db.Create(&contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
deliveredMessage := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "delivered", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "wamid-delivered-1"}
|
||||
readMessage := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "read", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusDelivered), SourceID: "wamid-read-1"}
|
||||
if err := db.Create(&deliveredMessage).Error; err != nil {
|
||||
t.Fatalf("create delivered message: %v", err)
|
||||
}
|
||||
if err := db.Create(&readMessage).Error; err != nil {
|
||||
t.Fatalf("create read message: %v", err)
|
||||
}
|
||||
|
||||
waRepo := whatsappchannel.NewRepository(db)
|
||||
waService := whatsappchannel.NewWhatsAppService(waRepo)
|
||||
waPipeline := whatsappchannel.NewIncomingPipeline(waService)
|
||||
waProvider := whatsappchannel.NewWhatsAppProvider(waService, waRepo, waPipeline)
|
||||
waWebhook := whatsappchannel.NewWebhookHandler(waProvider)
|
||||
dispatcher := channel.NewDispatcher()
|
||||
listener := &recordingListener{}
|
||||
dispatcher.Register(listener)
|
||||
h := NewWhatsAppWebhookHandler(waProvider, waWebhook, db, dispatcher)
|
||||
r := gin.New()
|
||||
r.POST("/webhooks/whatsapp/:phone_number", h.HandleWhatsAppWebhook)
|
||||
body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"waba-1","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"+15551230000","phone_number_id":"phone-id-1"},"statuses":[{"id":"wamid-delivered-1","status":"delivered","timestamp":"1710000200","recipient_id":"15550002222"},{"id":"wamid-read-1","status":"read","timestamp":"1710000300","recipient_id":"15550002222"}]}}]}]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/+15551230000", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Hub-Signature-256", metaSignature("wa-secret", body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
assertWebhookMessageStatus(t, db, deliveredMessage.ID, model.MessageStatusDelivered)
|
||||
assertWebhookMessageStatus(t, db, readMessage.ID, model.MessageStatusRead)
|
||||
var delivered model.DeliveryStatus
|
||||
if err := db.Where("message_id = ? AND contact_id = ?", deliveredMessage.ID, contact.ID).First(&delivered).Error; err != nil {
|
||||
t.Fatalf("expected delivered delivery status: %v", err)
|
||||
}
|
||||
if delivered.Status != model.MessageStatusDelivered || delivered.DeliveredAt == nil {
|
||||
t.Fatalf("expected delivered status timestamp, got %#v", delivered)
|
||||
}
|
||||
var read model.DeliveryStatus
|
||||
if err := db.Where("message_id = ? AND contact_id = ?", readMessage.ID, contact.ID).First(&read).Error; err != nil {
|
||||
t.Fatalf("expected read delivery status: %v", err)
|
||||
}
|
||||
if read.Status != model.MessageStatusRead || read.ReadAt == nil {
|
||||
t.Fatalf("expected read status timestamp, got %#v", read)
|
||||
}
|
||||
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
||||
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
||||
}
|
||||
|
||||
duplicateBody := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"waba-1","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"+15551230000","phone_number_id":"phone-id-1"},"statuses":[{"id":"wamid-delivered-1","status":"delivered","timestamp":"1710000400","recipient_id":"15550002222"}]}}]}]}`)
|
||||
duplicateReq := httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/+15551230000", bytes.NewReader(duplicateBody))
|
||||
duplicateReq.Header.Set("Content-Type", "application/json")
|
||||
duplicateReq.Header.Set("X-Hub-Signature-256", metaSignature("wa-secret", duplicateBody))
|
||||
duplicate := httptest.NewRecorder()
|
||||
r.ServeHTTP(duplicate, duplicateReq)
|
||||
if duplicate.Code != http.StatusOK {
|
||||
t.Fatalf("expected duplicate callback 200, got %d body=%s", duplicate.Code, duplicate.Body.String())
|
||||
}
|
||||
var deliveredCount int64
|
||||
if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", deliveredMessage.ID, contact.ID).Count(&deliveredCount).Error; err != nil {
|
||||
t.Fatalf("count delivered status rows: %v", err)
|
||||
}
|
||||
if deliveredCount != 1 {
|
||||
t.Fatalf("expected duplicate callback to keep one delivery status row, got %d", deliveredCount)
|
||||
}
|
||||
if len(listener.events) != 2 {
|
||||
t.Fatalf("expected duplicate callback not to dispatch another status event, got %#v", listener.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatsAppWebhookVerificationEchoesChallenge(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := newWebhookLookupTestDB(t)
|
||||
@@ -1237,6 +1891,85 @@ func TestTikTokWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
assertPersistedMessage(t, db, inbox.ID, "tt-msg-1", "hello tiktok")
|
||||
}
|
||||
|
||||
func TestTikTokWebhookReadReceiptUpdatesMessageStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "tiktok")
|
||||
channelRecord := channelmodel.ChannelTikTok{AccountID: 1, InboxID: inbox.ID, TikTokBusinessID: "biz-123", WebhookVerifyToken: "verify-token"}
|
||||
if err := db.Create(&channelRecord).Error; err != nil {
|
||||
t.Fatalf("create tiktok channel: %v", err)
|
||||
}
|
||||
contact := model.Contact{AccountID: inbox.AccountID, Name: "TikTok Contact"}
|
||||
if err := db.Create(&contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
if err := db.Create(&conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "outbound tiktok", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusDelivered), SourceID: "tt-out-1"}
|
||||
if err := db.Create(&message).Error; err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
dispatcher := channel.NewDispatcher()
|
||||
listener := &recordingListener{}
|
||||
dispatcher.Register(listener)
|
||||
ttRepo := tiktokchannel.NewRepository(db)
|
||||
ttService := tiktokchannel.NewTikTokService(ttRepo)
|
||||
ttPipeline := tiktokchannel.NewIncomingProcessor(ttService, ttRepo)
|
||||
ttWebhook := tiktokchannel.NewWebhookHandler(ttService, ttPipeline)
|
||||
h := NewTikTokWebhookHandler(ttWebhook, ttPipeline, db, dispatcher)
|
||||
r := gin.New()
|
||||
r.POST("/webhooks/tiktok", h.HandleTikTokWebhook)
|
||||
body := []byte(`{"type":"message.read","timestamp":1710000001,"biz_id":"biz-123","data":{"message_id":"tt-out-1","from_user_id":"tt-user-1","timestamp":1710000001}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/tiktok", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Tiktok-Signature", tiktokSignature("tiktok-secret", time.Now().Unix(), body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var updated model.Message
|
||||
if err := db.First(&updated, message.ID).Error; err != nil {
|
||||
t.Fatalf("load updated message: %v", err)
|
||||
}
|
||||
if updated.Status != string(model.MessageStatusRead) {
|
||||
t.Fatalf("expected read message status, got %s", updated.Status)
|
||||
}
|
||||
var delivery model.DeliveryStatus
|
||||
if err := db.Where("message_id = ? AND contact_id = ?", updated.ID, contact.ID).First(&delivery).Error; err != nil {
|
||||
t.Fatalf("expected delivery status: %v", err)
|
||||
}
|
||||
if delivery.Status != model.MessageStatusRead {
|
||||
t.Fatalf("expected read delivery status, got %#v", delivery)
|
||||
}
|
||||
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
||||
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
||||
}
|
||||
retry := httptest.NewRecorder()
|
||||
retryReq := httptest.NewRequest(http.MethodPost, "/webhooks/tiktok", bytes.NewReader(body))
|
||||
retryReq.Header.Set("Content-Type", "application/json")
|
||||
retryReq.Header.Set("Tiktok-Signature", tiktokSignature("tiktok-secret", time.Now().Unix(), body))
|
||||
r.ServeHTTP(retry, retryReq)
|
||||
if retry.Code != http.StatusOK {
|
||||
t.Fatalf("expected duplicate TikTok read ack, got %d body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
var deliveryCount int64
|
||||
if err := db.Model(&model.DeliveryStatus{}).Where("message_id = ? AND contact_id = ?", updated.ID, contact.ID).Count(&deliveryCount).Error; err != nil {
|
||||
t.Fatalf("count delivery statuses after retry: %v", err)
|
||||
}
|
||||
if deliveryCount != 1 {
|
||||
t.Fatalf("expected duplicate read receipt to keep one delivery status row, got %d", deliveryCount)
|
||||
}
|
||||
if len(listener.events) != 1 {
|
||||
t.Fatalf("expected duplicate read receipt not to dispatch another status event, got %#v", listener.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokWebhookRejectsInvalidSignature(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
||||
|
||||
Reference in New Issue
Block a user