From 55295ddc073d031ed9c6d599d216567e25edbd9f Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 00:21:42 +0800 Subject: [PATCH] test(webhook): cover provider ingress persistence fixtures --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 9 +- .../handler/webhook/webhook_lookup_test.go | 140 ++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 70e6b43b..7e020b84 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -1,6 +1,6 @@ # Chatwoot Parity Development Plan -Updated: 2026-06-04 +Updated: 2026-06-05 ## Goal @@ -16,7 +16,7 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Latest committed head: `bc7da9e feat(webhook): implement instagram and shopify ingress`. +- Latest committed head: `06b999b feat(webhook): dispatch persisted provider events`. - Worktree status at this planning checkpoint: clean. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 801` after Chatwoot webhook ingress routes were added. @@ -62,7 +62,8 @@ This ledger records the committed parity checkpoints that future slices should b | `bc7da9e feat(webhook): implement instagram and shopify ingress` | Implemented Instagram verify/event handling, Shopify HMAC/redact/event forwarding, and WhatsApp verify-token/app-secret signature corrections. | Focused webhook tests, `go test ./...`, route dump `TOTAL: 801`, route parity `251 exact, 0 missing`, `git diff --check`. | Remaining P6.7 work is durable incoming-message persistence and provider dispatch parity. | | `5eb726b feat(webhook): persist incoming provider messages` | Added provider incoming-message persistence boundary and wired Telegram, LINE, SMS/Twilio, WhatsApp, Facebook/Instagram, and TikTok parsed incoming messages into ContactInbox, Conversation, and Message storage. | Focused webhook/channel tests passed; full `go test ./...` passed. | Continue P6.7 review with delivery/read receipt status updates and async dispatch/events. | | `66ecabb feat(webhook): persist provider receipt statuses` | Added delivery/read/failed status persistence for Twilio, WhatsApp, Facebook/Instagram, and TikTok receipt events. | Focused webhook/channel tests passed; full `go test ./...` passed. | Continue P6.7 review with async dispatch/events and broader provider fixture assertions. | -| Working tree | Wired webhook incoming persistence and status updates into the existing `channel.Dispatcher` fan-out boundary. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with broader provider fixture assertions. | +| `06b999b feat(webhook): dispatch persisted provider events` | Wired webhook incoming persistence and status updates into the existing `channel.Dispatcher` fan-out boundary. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with broader provider fixture assertions. | +| Working tree | Added provider-specific webhook persistence fixture assertions for LINE, Twilio SMS, WhatsApp, Instagram, and TikTok, extending the existing Telegram fixture. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with signature edge fixtures and final provider Done/Review classification. | ## Next Slice Contract @@ -83,6 +84,7 @@ Current N1/N2 implementation checkpoint: - Added regression coverage for direct persistence, duplicate suppression, and Telegram webhook-to-message persistence. Broader provider-specific persistence assertions remain required before P6.7 is `Done`. - Added receipt persistence for status-only webhook events. Twilio delivery callbacks, WhatsApp statuses, Facebook/Instagram delivery/read receipts, and TikTok read receipts now update existing message statuses through the same boundary. Async event fan-out remains the next dispatch gap. - Wired the same boundary into `channel.Dispatcher` so incoming webhooks emit `contact.created`, `conversation.created/opened/updated`, `message.created/incoming`, and `message.status_updated` events for automation, CSAT, bot rules, notifications, and future async workers. +- Added provider-specific persistence fixtures for Telegram, LINE, Twilio SMS, WhatsApp, Instagram, and TikTok. These tests assert durable `messages` rows by provider source ID instead of only checking webhook `200 OK` acknowledgements. ## Immediate Execution Queue @@ -603,3 +605,4 @@ Verification milestone gates: - 2026-06-04: Added the P6.7 incoming persistence boundary. Provider webhook handlers now persist normalized incoming messages into `contacts`, `contact_inboxes`, open `conversations`, and incoming `messages` instead of only parsing/logging them. Telegram, LINE, Twilio SMS, WhatsApp, Facebook/Instagram, and TikTok are wired through the shared persister; duplicates are skipped by `inbox_id + source_id`. Added direct persister coverage and Telegram webhook persistence coverage. Focused webhook/channel tests and full `go test ./...` passed. - 2026-06-04: Continued P6.7 dispatch parity by persisting provider receipt/status events. Existing messages are updated from Twilio delivery callbacks, WhatsApp sent/delivered/read/failed statuses, Facebook/Instagram delivery/read receipts, and TikTok read receipts. Added tests for direct status update and Twilio delivery callback update. Focused webhook/channel tests and full `go test ./...` passed. - 2026-06-05: Wired P6.7 incoming persistence into the existing dispatcher fan-out boundary. Newly persisted webhook contacts, conversations, messages, and message status updates now emit `ChannelEvent`s through `DispatchAsync`'s current sync fallback, keeping automation, bot rule, CSAT, and notification listeners reachable from provider webhooks. Added listener-based regression coverage for incoming and status events. Focused webhook tests and full `go test ./...` passed. +- 2026-06-05: Broadened P6.7 provider webhook persistence fixtures. LINE, Twilio SMS, WhatsApp, Instagram, and TikTok webhook tests now assert persisted incoming `messages` by provider source ID, matching the existing Telegram persistence fixture and reducing the remaining provider-review surface to signature edge cases and final unsupported-provider classification. Focused webhook tests and full `go test ./...` passed. diff --git a/internal/handler/webhook/webhook_lookup_test.go b/internal/handler/webhook/webhook_lookup_test.go index 770e5441..aeb911d3 100644 --- a/internal/handler/webhook/webhook_lookup_test.go +++ b/internal/handler/webhook/webhook_lookup_test.go @@ -10,12 +10,17 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/channel" + linechannel "github.com/gochat/gochat/internal/channel/line" channelprovider "github.com/gochat/gochat/internal/channel/provider" + tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok" + twiliochannel "github.com/gochat/gochat/internal/channel/twilio" + whatsappchannel "github.com/gochat/gochat/internal/channel/whatsapp" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "gorm.io/driver/sqlite" @@ -51,6 +56,7 @@ func newWebhookLookupTestDB(t *testing.T) *gorm.DB { &channelmodel.ChannelTelegram{}, &channelmodel.ChannelLINE{}, &channelmodel.ChannelTwilioSMS{}, + &channelmodel.ChannelWhatsApp{}, &channelmodel.ChannelTikTok{}, &channelmodel.ChannelInstagram{}, &model.IntegrationHook{}, @@ -288,6 +294,34 @@ func TestLineWebhookLookupInboxByLineChannelID(t *testing.T) { } } +func TestLineWebhookPersistsIncomingMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "line") + 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-1","text":"hello line"}}]}`) + req := httptest.NewRequest(http.MethodPost, "/webhooks/line/line-channel-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + 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()) + } + assertPersistedMessage(t, db, inbox.ID, "line-msg-1", "hello line") +} + func TestTwilioWebhookLookupInboxByPhoneNumber(t *testing.T) { db := newWebhookLookupTestDB(t) inbox := seedWebhookInbox(t, db, "twilio_sms") @@ -312,6 +346,40 @@ func TestTwilioWebhookLookupInboxByPhoneNumber(t *testing.T) { } } +func TestTwilioWebhookPersistsIncomingMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "twilio_sms") + channelRecord := channelmodel.ChannelTwilioSMS{AccountID: 1, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567"} + if err := db.Create(&channelRecord).Error; err != nil { + t.Fatalf("create twilio channel: %v", err) + } + + twilioRepo := twiliochannel.NewRepository(db) + twilioService := twiliochannel.NewTwilioService(twilioRepo) + twilioPipeline := twiliochannel.NewIncomingProcessor(twilioService) + twilioWebhook := twiliochannel.NewWebhookHandler(twilioPipeline, twilioService) + h := NewTwilioWebhookHandler(twilioWebhook, db) + r := gin.New() + r.POST("/webhooks/sms/:phone_number", h.HandleTwilioInboundSMS) + form := url.Values{} + form.Set("MessageSid", "SMIN1") + form.Set("AccountSid", "AC123") + form.Set("From", "+15550002222") + form.Set("To", "+15551234567") + form.Set("Body", "hello sms") + req := httptest.NewRequest(http.MethodPost, "/webhooks/sms/+15551234567", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + 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()) + } + assertPersistedMessage(t, db, inbox.ID, "SMIN1", "hello sms") +} + func TestTwilioDeliveryStatusUpdatesExistingMessage(t *testing.T) { gin.SetMode(gin.TestMode) db := newWebhookLookupTestDB(t) @@ -358,6 +426,36 @@ func TestTwilioDeliveryStatusUpdatesExistingMessage(t *testing.T) { } } +func TestWhatsAppWebhookPersistsIncomingMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "whatsapp") + waChannel := channelmodel.ChannelWhatsApp{AccountID: 1, InboxID: inbox.ID, PhoneNumber: "+15551230000", PhoneNumberID: "phone-id-1", AccessToken: "token"} + if err := db.Create(&waChannel).Error; err != nil { + t.Fatalf("create whatsapp channel: %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"},"contacts":[{"wa_id":"15550001111","profile":{"name":"WhatsApp User"}}],"messages":[{"from":"15550001111","id":"wamid-1","timestamp":"1710000000","type":"text","text":{"body":"hello whatsapp"}}]}}]}]}`) + req := httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/+15551230000", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + 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()) + } + assertPersistedMessage(t, db, inbox.ID, "wamid-1", "hello whatsapp") +} + func TestTikTokWebhookLookupInboxByBusinessIDAndPayloadExtractor(t *testing.T) { db := newWebhookLookupTestDB(t) inbox := seedWebhookInbox(t, db, "tiktok") @@ -385,6 +483,35 @@ func TestTikTokWebhookLookupInboxByBusinessIDAndPayloadExtractor(t *testing.T) { } } +func TestTikTokWebhookPersistsIncomingMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + 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) + } + + ttRepo := tiktokchannel.NewRepository(db) + ttService := tiktokchannel.NewTikTokService(ttRepo) + ttPipeline := tiktokchannel.NewIncomingProcessor(ttService, ttRepo) + ttWebhook := tiktokchannel.NewWebhookHandler(ttService, ttPipeline) + h := NewTikTokWebhookHandler(ttWebhook, ttPipeline, db) + r := gin.New() + r.POST("/webhooks/tiktok", h.HandleTikTokWebhook) + body := []byte(`{"type":"message.received","timestamp":1710000000,"biz_id":"biz-123","data":{"message_id":"tt-msg-1","from_user_id":"tt-user-1","to_user_id":"biz-123","content_type":"text","content":"hello tiktok","timestamp":1710000000,"conversation_id":"tt-conv-1"}}`) + req := httptest.NewRequest(http.MethodPost, "/webhooks/tiktok", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + 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()) + } + assertPersistedMessage(t, db, inbox.ID, "tt-msg-1", "hello tiktok") +} + func TestShopifyWebhookShopRedactDeletesMatchingHook(t *testing.T) { gin.SetMode(gin.TestMode) db := newWebhookLookupTestDB(t) @@ -490,4 +617,17 @@ func TestInstagramWebhookEventsVerifySignatureAndResolveInbox(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } + assertPersistedMessage(t, db, inbox.ID, "mid-1", "hello") +} + +func assertPersistedMessage(t *testing.T, db *gorm.DB, inboxID uint, sourceID string, content string) model.Message { + t.Helper() + var message model.Message + if err := db.Where("inbox_id = ? AND source_id = ?", inboxID, sourceID).First(&message).Error; err != nil { + t.Fatalf("expected message source_id=%s persisted: %v", sourceID, err) + } + if message.Content != content || message.MessageType != string(model.MessageTypeIncoming) { + t.Fatalf("unexpected message source_id=%s: %#v", sourceID, message) + } + return message }