Align GoChat with Chatwoot frontend contracts

This commit is contained in:
2026-06-13 22:13:32 +08:00
parent 71abf58636
commit d884fdda0a
162 changed files with 9825 additions and 528 deletions
+749 -16
View File
@@ -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")