Files
Bruno ClermontandBruno Clermont 72fcf52f5d fix(channels): propagate tenant context through all channel handlers (#1278)
Pairing requests and message handlers were silently using MasterTenantID
instead of the correct tenant because context.Context had no tenant set.

Root cause: instance_loader.go passed bare ctx (no tenant) to
startChannelWithTimeout; channel handlers also missing store.WithTenantID
at entry points.

Channels fixed:
- instance_loader.go: pass instCtx (with tenant) to startChannelWithTimeout
- Telegram, Bitrix24, Feishu: add store.WithTenantID at handler entry
- Facebook: add ctx param to handleMessagingEvent/handleCommentEvent + webhook_handler
- Pancake: add ctx param to handleMessagingEvent/handleCommentEvent + webhook_handler

Also fix consolidation pruner: PruneExpired SQL had tenant_id filter that
caused zero rows to ever be deleted (context had no tenant). Remove tenant
filter — pruning is a global maintenance operation.

Add regression tests:
- internal/channels/pancake/tenant_context_test.go (6 tests)
- internal/channels/facebook/tenant_context_test.go (4 tests)
- internal/consolidation/prune_expired_test.go (5 tests)

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
2026-06-25 07:38:51 +07:00

1244 lines
40 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pancake
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
)
// TestFactory_Valid verifies Factory creates a Channel from valid JSON creds/config.
func TestFactory_Valid(t *testing.T) {
creds, _ := json.Marshal(pancakeCreds{
APIKey: "test-api-key",
PageAccessToken: "test-page-token",
})
cfg, _ := json.Marshal(pancakeInstanceConfig{PageID: "12345"})
ch, err := Factory("pancake-test", creds, cfg, nil, nil)
if err != nil {
t.Fatalf("Factory returned unexpected error: %v", err)
}
if ch == nil {
t.Fatal("Factory returned nil channel")
}
if ch.Name() != "pancake-test" {
t.Errorf("Name() = %q, want %q", ch.Name(), "pancake-test")
}
}
// TestFactory_MissingAPIKey verifies Factory returns error when api_key is empty.
func TestFactory_MissingAPIKey(t *testing.T) {
creds, _ := json.Marshal(pancakeCreds{PageAccessToken: "token"})
cfg, _ := json.Marshal(pancakeInstanceConfig{PageID: "12345"})
_, err := Factory("test", creds, cfg, nil, nil)
if err == nil {
t.Fatal("expected error for missing api_key, got nil")
}
}
// TestFactory_MissingPageAccessToken verifies Factory returns error when page_access_token is empty.
func TestFactory_MissingPageAccessToken(t *testing.T) {
creds, _ := json.Marshal(pancakeCreds{APIKey: "key"})
cfg, _ := json.Marshal(pancakeInstanceConfig{PageID: "12345"})
_, err := Factory("test", creds, cfg, nil, nil)
if err == nil {
t.Fatal("expected error for missing page_access_token, got nil")
}
}
// TestFactory_MissingPageID verifies Factory returns error when page_id is empty.
func TestFactory_MissingPageID(t *testing.T) {
creds, _ := json.Marshal(pancakeCreds{
APIKey: "key",
PageAccessToken: "token",
})
cfg, _ := json.Marshal(pancakeInstanceConfig{}) // no page_id
_, err := Factory("test", creds, cfg, nil, nil)
if err == nil {
t.Fatal("expected error for missing page_id, got nil")
}
}
// TestFormatOutbound verifies platform-aware formatting for each platform.
func TestFormatOutbound(t *testing.T) {
input := "**Hello** _world_ `code` ## Header [link](http://example.com)"
cases := []struct {
platform string
wantNot string // substring that should NOT appear in output
}{
{"facebook", "**"},
{"zalo", "**"},
{"instagram", "_"},
{"tiktok", "##"},
{"whatsapp", "**"},
{"line", "##"},
{"unknown", "`"},
}
for _, tc := range cases {
t.Run(tc.platform, func(t *testing.T) {
out := FormatOutbound(input, tc.platform)
if out == "" {
t.Error("FormatOutbound returned empty string")
}
_ = out // formatting verified visually; we just check no panic + non-empty
})
}
}
// TestSplitMessage verifies message splitting at platform character limits.
func TestSplitMessage(t *testing.T) {
t.Run("short message not split", func(t *testing.T) {
parts := splitMessage("hello", 100)
if len(parts) != 1 || parts[0] != "hello" {
t.Errorf("unexpected parts: %v", parts)
}
})
t.Run("exact limit not split", func(t *testing.T) {
msg := string(make([]byte, 100))
parts := splitMessage(msg, 100)
if len(parts) != 1 {
t.Errorf("expected 1 part, got %d", len(parts))
}
})
t.Run("over limit is split", func(t *testing.T) {
msg := string(make([]byte, 250))
parts := splitMessage(msg, 100)
if len(parts) != 3 {
t.Errorf("expected 3 parts, got %d", len(parts))
}
})
t.Run("zero limit returns whole string", func(t *testing.T) {
parts := splitMessage("hello", 0)
if len(parts) != 1 {
t.Errorf("expected 1 part with zero limit, got %d", len(parts))
}
})
}
// TestIsDup verifies dedup returns false first, true on repeat.
func TestIsDup(t *testing.T) {
ch := &Channel{}
if ch.isDup("key-1") {
t.Error("isDup: first call should return false")
}
if !ch.isDup("key-1") {
t.Error("isDup: second call should return true")
}
if ch.isDup("key-2") {
t.Error("isDup: different key should return false")
}
}
// TestWebhookRouterReturns200 verifies the global router always returns HTTP 200.
func TestWebhookRouterReturns200(t *testing.T) {
// Use a fresh local router to avoid interfering with the package-level globalRouter.
router := &webhookRouter{instances: make(map[string]*Channel)}
t.Run("POST event returns 200", func(t *testing.T) {
body := `{"data":{"conversation":{"id":"123_456","type":"INBOX","from":{"id":"456"}},"message":{"id":"m1"}}}`
req := httptest.NewRequest(http.MethodPost, "/channels/pancake/webhook",
strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
})
t.Run("GET returns 200 (not 405)", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/channels/pancake/webhook", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
})
t.Run("malformed JSON returns 200", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/channels/pancake/webhook",
strings.NewReader("not-json"))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
})
}
// TestMessageHandlerSkipsSelfReply verifies the page's own messages are not published.
// The dedup entry is stored (dedup runs first), but HandleMessage is never called.
// If the self-reply guard were absent, ch.bus (nil) would panic — making no-panic the assertion.
func TestMessageHandlerSkipsSelfReply(t *testing.T) {
const pageID = "page-123"
msgBus := bus.New()
ch := &Channel{
BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
pageID: pageID,
}
data := MessagingData{
PageID: pageID,
ConversationID: "conv-1",
Type: "INBOX",
Platform: "facebook",
Message: MessagingMessage{
ID: "msg-self-1",
SenderID: pageID, // same as page → must be skipped before HandleMessage
SenderName: "Page Bot",
Content: "Hello",
},
}
// Must not panic. If self-reply guard is missing, nil bus dereference panics here.
ch.handleMessagingEvent(context.Background(), data)
// Dedup entry is stored (dedup check runs before self-reply check).
_, stored := ch.dedup.Load("msg:msg-self-1")
if !stored {
t.Error("dedup entry should have been stored (dedup runs before self-reply guard)")
}
}
func TestMessageHandlerPublishesMessageIDMetadata(t *testing.T) {
msgBus := bus.New()
ch := &Channel{
BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
pageID: "page-123",
}
ch.handleMessagingEvent(context.Background(), MessagingData{
PageID: "page-123",
ConversationID: "conv-1",
Type: "INBOX",
Platform: "facebook",
Message: MessagingMessage{
ID: "msg-123",
SenderID: "user-1",
Content: "hello",
},
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
msg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected inbound message to be published")
}
if got, want := msg.Metadata["message_id"], "msg:msg-123"; got != want {
t.Fatalf("metadata.message_id = %q, want %q", got, want)
}
}
func TestMessageHandlerSkipsRecentOutboundEcho(t *testing.T) {
msgBus := bus.New()
ch := &Channel{
BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
pageID: "page-123",
}
ch.rememberOutboundEcho("conv-1", "hello from bot")
ch.handleMessagingEvent(context.Background(), MessagingData{
PageID: "page-123",
ConversationID: "conv-1",
Type: "INBOX",
Platform: "facebook",
Message: MessagingMessage{
ID: "msg-echo-1",
SenderID: "user-1",
Content: "hello from bot",
},
})
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatal("expected echoed outbound message to be dropped")
}
}
func TestBlockReplyEnabledUsesChannelOverride(t *testing.T) {
enabled := true
ch := &Channel{
config: pancakeInstanceConfig{
BlockReply: &enabled,
},
}
got := ch.BlockReplyEnabled()
if got == nil || !*got {
t.Fatalf("BlockReplyEnabled() = %v, want true", got)
}
}
type captureTransport struct {
req *http.Request
body []byte
resp *http.Response
}
func (t *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.req = req.Clone(req.Context())
if req.Body != nil {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
t.body = body
req.Body = io.NopCloser(bytes.NewReader(body))
}
if t.resp != nil {
return t.resp, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"success":true}`)),
Request: req,
}, nil
}
func TestAPIClientSendMessageMatchesOfficialContract(t *testing.T) {
transport := &captureTransport{}
client := NewAPIClient("user-token", "page-token", "page-123")
client.httpClient = &http.Client{Transport: transport}
if err := client.SendMessage(context.Background(), "conv-456", "xin chao"); err != nil {
t.Fatalf("SendMessage returned error: %v", err)
}
if transport.req == nil {
t.Fatal("expected outbound request to be captured")
}
if got, want := transport.req.URL.Path, "/api/public_api/v1/pages/page-123/conversations/conv-456/messages"; got != want {
t.Fatalf("request path = %q, want %q", got, want)
}
if got := transport.req.URL.Query().Get("page_access_token"); got != "page-token" {
t.Fatalf("page_access_token query = %q, want %q", got, "page-token")
}
var payload map[string]any
if err := json.Unmarshal(transport.body, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if got, want := payload["action"], "reply_inbox"; got != want {
t.Fatalf("payload.action = %#v, want %#v", got, want)
}
if got, want := payload["message"], "xin chao"; got != want {
t.Fatalf("payload.message = %#v, want %#v", got, want)
}
if _, exists := payload["content"]; exists {
t.Fatalf("payload must not contain legacy content field: %s", string(transport.body))
}
if _, exists := payload["attachment_id"]; exists {
t.Fatalf("payload must not contain attachment_id field: %s", string(transport.body))
}
}
func TestAPIClientSendMessageReturnsBodyLevelError(t *testing.T) {
transport := &captureTransport{
resp: &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"success":false,"message":"conversation blocked"}`)),
},
}
client := NewAPIClient("user-token", "page-token", "page-123")
client.httpClient = &http.Client{Transport: transport}
err := client.SendMessage(context.Background(), "conv-456", "xin chao")
if err == nil {
t.Fatal("expected SendMessage to return body-level error")
}
if !strings.Contains(err.Error(), "conversation blocked") {
t.Fatalf("SendMessage error = %v, want body-level message", err)
}
}
// TestTruncateRuneSafe_MultiByteCharacters verifies rune-safe truncation for
// Vietnamese diacritics and emoji (multi-byte UTF-8 sequences).
func TestTruncateRuneSafe_MultiByteCharacters(t *testing.T) {
// Vietnamese text with diacritics (multi-byte UTF-8)
input := strings.Repeat("Xin chào ", 100) // ~900 bytes, <500 runes
result := truncateRuneSafe(input, 500)
if !utf8.ValidString(result) {
t.Fatal("truncateRuneSafe produced invalid UTF-8")
}
// Emoji string exceeding 500 runes
emoji := strings.Repeat("😊", 600)
result = truncateRuneSafe(emoji, 500)
runes := []rune(result)
if len(runes) > 500 {
t.Errorf("expected <=500 runes, got %d", len(runes))
}
if !utf8.ValidString(result) {
t.Fatal("emoji truncation produced invalid UTF-8")
}
}
// --- Shopee platform support tests (Phase 1: TDD red state) ---
// TestMaxMessageLength_Shopee verifies shopee returns 500 char limit.
func TestMaxMessageLength_Shopee(t *testing.T) {
ch := &Channel{platform: "shopee"}
if got := ch.maxMessageLength(); got != 500 {
t.Fatalf("shopee maxMessageLength = %d, want 500", got)
}
// Regression guards for existing platforms.
for _, tc := range []struct {
p string
want int
}{
{"tiktok", 500}, {"facebook", 2000}, {"whatsapp", 4096},
} {
ch.platform = tc.p
if got := ch.maxMessageLength(); got != tc.want {
t.Fatalf("%s maxMessageLength = %d, want %d", tc.p, got, tc.want)
}
}
}
// TestTruncateRuneSafe_Shopee verifies FormatOutbound truncates shopee to 500 runes.
// Uses Vietnamese diacritics and emoji to catch byte-vs-rune bugs.
func TestTruncateRuneSafe_Shopee(t *testing.T) {
// Vietnamese text: 600 "Xin chào " iterations → >500 runes.
input := strings.Repeat("Xin chào ", 100)
out := FormatOutbound(input, "shopee")
if utf8.RuneCountInString(out) > 500 {
t.Fatalf("shopee output = %d runes, want <=500", utf8.RuneCountInString(out))
}
if !utf8.ValidString(out) {
t.Fatal("shopee truncation produced invalid UTF-8")
}
// Emoji-only input exceeding 500 runes.
emoji := strings.Repeat("😊", 600)
out = FormatOutbound(emoji, "shopee")
if utf8.RuneCountInString(out) > 500 {
t.Fatalf("emoji shopee output = %d runes, want <=500", utf8.RuneCountInString(out))
}
}
// TestMessageHandlerEmptyMessageID verifies that two messages with empty IDs
// from different conversations are both published (not deduped against each other).
func TestMessageHandlerEmptyMessageID(t *testing.T) {
msgBus := bus.New()
ch := &Channel{
BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
pageID: "page-123",
}
// First message with empty ID — should be published
ch.handleMessagingEvent(context.Background(), MessagingData{
PageID: "page-123", ConversationID: "conv-1",
Type: "INBOX", Platform: "facebook",
Message: MessagingMessage{ID: "", SenderID: "user-1", Content: "hello"},
})
// Second message with empty ID, different conversation — should ALSO be published
ch.handleMessagingEvent(context.Background(), MessagingData{
PageID: "page-123", ConversationID: "conv-2",
Type: "INBOX", Platform: "facebook",
Message: MessagingMessage{ID: "", SenderID: "user-2", Content: "world"},
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, ok1 := msgBus.ConsumeInbound(ctx)
if !ok1 {
t.Fatal("first empty-ID message should be published")
}
_, ok2 := msgBus.ConsumeInbound(ctx)
if !ok2 {
t.Fatal("second empty-ID message should NOT be deduped against first")
}
}
func TestAPIClientUploadMediaMatchesOfficialContract(t *testing.T) {
transport := &captureTransport{
resp: &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"id":"upload-123","success":true}`)),
},
}
client := NewAPIClient("user-token", "page-token", "page-123")
client.httpClient = &http.Client{Transport: transport}
id, err := client.UploadMedia(context.Background(), "photo.jpg", strings.NewReader("file-bytes"), "image/jpeg")
if err != nil {
t.Fatalf("UploadMedia returned error: %v", err)
}
if id != "upload-123" {
t.Fatalf("UploadMedia id = %q, want %q", id, "upload-123")
}
if transport.req == nil {
t.Fatal("expected upload request to be captured")
}
if got, want := transport.req.URL.Path, "/api/public_api/v1/pages/page-123/upload_contents"; got != want {
t.Fatalf("upload path = %q, want %q", got, want)
}
if got := transport.req.URL.Query().Get("page_access_token"); got != "page-token" {
t.Fatalf("upload page_access_token query = %q, want %q", got, "page-token")
}
if !strings.HasPrefix(transport.req.Header.Get("Content-Type"), "multipart/form-data; boundary=") {
t.Fatalf("upload Content-Type = %q, want multipart/form-data", transport.req.Header.Get("Content-Type"))
}
}
// TestIsAuthError_WrappedError verifies errors.As works with wrapped apiError.
func TestIsAuthError_WrappedError(t *testing.T) {
inner := &apiError{Code: 401, Message: "unauthorized"}
wrapped := fmt.Errorf("send failed: %w", inner)
if !isAuthError(wrapped) {
t.Error("isAuthError should detect wrapped 401 apiError via errors.As")
}
if isAuthError(fmt.Errorf("random error")) {
t.Error("isAuthError should return false for non-apiError")
}
}
// TestIsRateLimitError_WrappedError verifies errors.As works with wrapped rate limit.
func TestIsRateLimitError_WrappedError(t *testing.T) {
inner := &apiError{Code: 429, Message: "too many requests"}
wrapped := fmt.Errorf("send failed: %w", inner)
if !isRateLimitError(wrapped) {
t.Error("isRateLimitError should detect wrapped 429 apiError via errors.As")
}
}
// buildWebhookBody builds a Pancake webhook JSON body for test use.
func buildWebhookBody(pageID, convID, convType, senderID, msgID, content, postID string) string {
conv := fmt.Sprintf(`{"id":%q,"type":%q,"from":{"id":%q}}`, convID, convType, senderID)
if postID != "" {
conv = fmt.Sprintf(`{"id":%q,"type":%q,"post_id":%q,"from":{"id":%q}}`, convID, convType, postID, senderID)
}
return fmt.Sprintf(`{"page_id":%q,"data":{"conversation":%s,"message":{"id":%q,"message":%q}}}`,
pageID, conv, msgID, content)
}
func signTestPancakeRequest(req *http.Request, body, secret string) {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
req.Header.Set("X-Pancake-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
// newTestRouter creates an isolated webhookRouter with a registered channel.
func newTestRouter(t *testing.T, cfg pancakeInstanceConfig) (*webhookRouter, *Channel, *bus.MessageBus) {
t.Helper()
msgBus := bus.New()
cfg.PageID = "page-test"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t", WebhookSecret: "test-secret"}
ch, err := New(cfg, creds, msgBus, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
ch.platform = "facebook"
router := &webhookRouter{instances: map[string]*Channel{"page-test": ch}}
return router, ch, msgBus
}
// --- Webhook Router ---
func TestWebhookRouterRoutesCommentEvent(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
router, ch, msgBus := newTestRouter(t, cfg)
ch.webhookSecret = "test-secret"
body := buildWebhookBody("page-test", "conv-1", "COMMENT", "user-1", "msg-1", "hello", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
msg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected message published for COMMENT event")
}
if msg.Metadata["pancake_mode"] != "comment" {
t.Errorf("pancake_mode = %q, want %q", msg.Metadata["pancake_mode"], "comment")
}
}
// TestWebhookRouterRoutesWebhookPageID covers the production Facebook COMMENT case:
// Pancake sends event.page_id = Facebook native page ID (780222461832476),
// but the channel is configured with Pancake's internal page ID (1098014820065543).
// Fix: configure webhook_page_id = "fb-native-id" so the router registers under both.
func TestWebhookRouterRoutesWebhookPageID(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
cfg.WebhookPageID = "fb-native-id" // Facebook native page ID sent in webhook event.page_id
msgBus := bus.New()
cfg.PageID = "pancake-internal-id"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t", WebhookSecret: "test-secret"}
ch, err := New(cfg, creds, msgBus, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
ch.platform = "facebook"
// Simulate register — both IDs should be in the router.
router := &webhookRouter{instances: make(map[string]*Channel)}
router.register(ch)
if router.instances["pancake-internal-id"] == nil {
t.Error("router should have channel registered under Pancake internal page ID")
}
if router.instances["fb-native-id"] == nil {
t.Error("router should have channel registered under Facebook native page ID (webhook_page_id)")
}
// Webhook arrives with Facebook native page ID — must route to the channel.
body := buildWebhookBody("fb-native-id", "conv-1", "COMMENT", "user-1", "msg-1", "hello", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
msg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected message published: webhook_page_id should route COMMENT to correct channel")
}
if msg.Metadata["pancake_mode"] != "comment" {
t.Errorf("pancake_mode = %q, want comment", msg.Metadata["pancake_mode"])
}
}
func TestWebhookRouterRoutesInboxEvent(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.InboxReply = true
router, ch, msgBus := newTestRouter(t, cfg)
ch.webhookSecret = "test-secret"
body := buildWebhookBody("page-test", "conv-1", "INBOX", "user-1", "msg-2", "inbox msg", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
msg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected message published for INBOX event")
}
// inbox handler sets pancake_mode = "inbox"
if msg.Metadata["pancake_mode"] != "inbox" {
t.Errorf("pancake_mode = %q, want %q", msg.Metadata["pancake_mode"], "inbox")
}
}
func TestWebhookRouterMissingSecretDoesNotDispatch(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.InboxReply = true
router, ch, msgBus := newTestRouter(t, cfg)
ch.webhookSecret = ""
body := buildWebhookBody("page-test", "conv-1", "INBOX", "user-1", "msg-2", "inbox msg", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected provider-safe 200, got %d", w.Code)
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatal("expected no dispatch when webhook secret is missing")
}
}
func TestWebhookRouterSignatureMismatchDoesNotDispatch(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.InboxReply = true
router, ch, msgBus := newTestRouter(t, cfg)
body := buildWebhookBody("page-test", "conv-1", "INBOX", "user-1", "msg-2", "inbox msg", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, "wrong-secret")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected provider-safe 200, got %d", w.Code)
}
if ch.webhookSecret == "" {
t.Fatal("test setup error: expected configured webhook secret")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatal("expected no dispatch on signature mismatch")
}
}
func TestWebhookRouterDuplicateSignedBodyDoesNotDispatchTwice(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.InboxReply = true
router, ch, msgBus := newTestRouter(t, cfg)
body := buildWebhookBody("page-test", "conv-1", "INBOX", "user-1", "", "inbox msg", "")
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d status = %d, want 200", i+1, w.Code)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, ok := msgBus.ConsumeInbound(ctx); !ok {
t.Fatal("expected first signed webhook to dispatch")
}
if _, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatal("expected duplicate signed webhook body to be skipped")
}
}
func TestWebhookRouterSkipsUnknownType(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
router, _, msgBus := newTestRouter(t, cfg)
body := buildWebhookBody("page-test", "conv-1", "UNKNOWN", "user-1", "msg-3", "ignored", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, ok := msgBus.ConsumeInbound(ctx)
if ok {
t.Error("expected no message for unknown conversation type")
}
}
func TestWebhookRouterCommentNormalizesPostID(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
router, ch, msgBus := newTestRouter(t, cfg)
ch.webhookSecret = "test-secret"
body := buildWebhookBody("page-test", "conv-1", "COMMENT", "user-1", "msg-4", "hello", "post-123")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
msg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected message published")
}
if msg.Metadata["post_id"] != "post-123" {
t.Errorf("metadata.post_id = %q, want %q", msg.Metadata["post_id"], "post-123")
}
}
// --- Send Path ---
// multiCaptureTransport records multiple requests (for first-inbox tests).
type multiCaptureTransport struct {
reqs []*http.Request
bodies [][]byte
mu sync.Mutex
}
func (t *multiCaptureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.mu.Lock()
defer t.mu.Unlock()
cloned := req.Clone(req.Context())
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(body))
}
t.reqs = append(t.reqs, cloned)
t.bodies = append(t.bodies, body)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"success":true}`)),
Request: req,
}, nil
}
func newChannelWithMultiCapture(t *testing.T, cfg pancakeInstanceConfig) (*Channel, *multiCaptureTransport) {
t.Helper()
transport := &multiCaptureTransport{}
msgBus := bus.New()
cfg.PageID = "page-123"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t", WebhookSecret: "test-secret"}
ch, err := New(cfg, creds, msgBus, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
ch.apiClient.httpClient = &http.Client{Transport: transport}
ch.platform = "facebook"
return ch, transport
}
func TestSend_CommentMode(t *testing.T) {
cfg := pancakeInstanceConfig{}
ch, transport := newChannelWithMultiCapture(t, cfg)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "conv-123",
Content: "reply text",
Metadata: map[string]string{
"pancake_mode": "comment",
"reply_to_comment_id": "msg-1",
"sender_id": "user-1",
},
})
if err != nil {
t.Fatalf("Send returned error: %v", err)
}
transport.mu.Lock()
defer transport.mu.Unlock()
if len(transport.reqs) == 0 {
t.Fatal("expected at least one request")
}
var payload map[string]any
json.Unmarshal(transport.bodies[0], &payload)
if payload["action"] != "reply_comment" {
t.Errorf("action = %v, want reply_comment", payload["action"])
}
if payload["message"] != "reply text" {
t.Errorf("message = %v, want 'reply text'", payload["message"])
}
}
func TestSend_CommentMode_MissingCommentID_ReturnsError(t *testing.T) {
cfg := pancakeInstanceConfig{}
ch, _ := newChannelWithMultiCapture(t, cfg)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "conv-123",
Content: "reply text",
Metadata: map[string]string{
"pancake_mode": "comment",
// reply_to_comment_id intentionally absent
},
})
if err == nil {
t.Fatal("expected error when reply_to_comment_id is missing, got nil")
}
if !strings.Contains(err.Error(), "reply_to_comment_id") {
t.Errorf("error message should mention reply_to_comment_id, got: %v", err)
}
}
func TestSend_CommentMode_WithPrivateReply(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "Thanks!"
ch, transport := newChannelWithMultiCapture(t, cfg)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "conv-123",
Content: "reply text",
Metadata: map[string]string{
"pancake_mode": "comment",
"sender_id": "user-1",
"reply_to_comment_id": "msg-1",
},
})
if err != nil {
t.Fatalf("Send returned error: %v", err)
}
transport.mu.Lock()
defer transport.mu.Unlock()
if len(transport.reqs) != 2 {
t.Fatalf("expected 2 requests (reply_comment + private_reply), got %d", len(transport.reqs))
}
var p1, p2 map[string]any
json.Unmarshal(transport.bodies[0], &p1)
json.Unmarshal(transport.bodies[1], &p2)
if p1["action"] != "reply_comment" {
t.Errorf("first request action = %v, want reply_comment", p1["action"])
}
if p2["action"] != "private_reply" {
t.Errorf("second request action = %v, want private_reply", p2["action"])
}
if p2["message"] != "Thanks!" {
t.Errorf("private_reply message = %v, want 'Thanks!'", p2["message"])
}
}
func TestSend_CommentMode_PrivateReplyStateless(t *testing.T) {
// Stateless: each Send() with PrivateReply enabled fires a DM.
// Dedup responsibility lives at the webhook layer (comment_id) and
// at Facebook's platform (per-comment private_replies idempotency).
cfg := pancakeInstanceConfig{}
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "DM!"
ch, transport := newChannelWithMultiCapture(t, cfg)
outMsg := bus.OutboundMessage{
ChatID: "conv-123",
Content: "hi",
Metadata: map[string]string{
"pancake_mode": "comment",
"sender_id": "user-1",
"reply_to_comment_id": "msg-1",
},
}
ch.Send(context.Background(), outMsg) //nolint:errcheck
outMsg.ChatID = "conv-456"
outMsg.Metadata["reply_to_comment_id"] = "msg-2"
ch.Send(context.Background(), outMsg) //nolint:errcheck
transport.mu.Lock()
defer transport.mu.Unlock()
// 2x reply_comment + 2x private_reply = 4 requests (stateless)
if len(transport.reqs) != 4 {
t.Fatalf("expected 4 requests (2x reply_comment + 2x private_reply, stateless), got %d", len(transport.reqs))
}
var privateCount int
for _, body := range transport.bodies {
var p map[string]any
json.Unmarshal(body, &p)
if p["action"] == "private_reply" {
privateCount++
}
}
if privateCount != 2 {
t.Errorf("expected 2 private_reply calls (stateless), got %d", privateCount)
}
}
func TestSend_CommentMode_PrivateReplyDisabled(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.PrivateReply = false
ch, transport := newChannelWithMultiCapture(t, cfg)
ch.Send(context.Background(), bus.OutboundMessage{ //nolint:errcheck
ChatID: "conv-123",
Content: "reply",
Metadata: map[string]string{
"pancake_mode": "comment",
"sender_id": "user-1",
"reply_to_comment_id": "msg-1",
},
})
transport.mu.Lock()
defer transport.mu.Unlock()
if len(transport.reqs) != 1 {
t.Fatalf("expected 1 request (reply_comment only), got %d", len(transport.reqs))
}
var p map[string]any
json.Unmarshal(transport.bodies[0], &p)
if p["action"] == "private_reply" {
t.Error("should not send private_reply when PrivateReply is disabled")
}
}
func TestSend_InboxMode_Unchanged(t *testing.T) {
cfg := pancakeInstanceConfig{}
ch, transport := newChannelWithMultiCapture(t, cfg)
ch.Send(context.Background(), bus.OutboundMessage{ //nolint:errcheck
ChatID: "conv-123",
Content: "inbox reply",
Metadata: map[string]string{
"pancake_mode": "inbox",
},
})
transport.mu.Lock()
defer transport.mu.Unlock()
if len(transport.reqs) == 0 {
t.Fatal("expected a request for inbox mode")
}
var p map[string]any
json.Unmarshal(transport.bodies[0], &p)
if p["action"] != "reply_inbox" {
t.Errorf("action = %v, want reply_inbox", p["action"])
}
}
func TestSend_CommentMode_EchoRemembered(t *testing.T) {
cfg := pancakeInstanceConfig{}
ch, _ := newChannelWithMultiCapture(t, cfg)
ch.Send(context.Background(), bus.OutboundMessage{ //nolint:errcheck
ChatID: "conv-echo",
Content: "some reply",
Metadata: map[string]string{
"pancake_mode": "comment",
"sender_id": "user-1",
"reply_to_comment_id": "msg-1",
},
})
if !ch.isRecentOutboundEcho("conv-echo", "some reply") {
t.Error("expected outbound echo to be remembered after Send")
}
}
// --- Private Reply ---
func TestSendPrivateReply_DefaultMessage(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "" // empty = use default
ch, transport := newChannelWithMultiCapture(t, cfg)
ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
transport.mu.Lock()
defer transport.mu.Unlock()
if len(transport.reqs) != 1 {
t.Fatalf("expected 1 request, got %d", len(transport.reqs))
}
var p map[string]any
json.Unmarshal(transport.bodies[0], &p)
if p["action"] != "private_reply" {
t.Errorf("action = %v, want private_reply", p["action"])
}
msg, _ := p["message"].(string)
if msg == "" {
t.Error("expected non-empty default private reply message")
}
}
func TestSendPrivateReply_CustomMessage(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "Thanks for your comment!"
ch, transport := newChannelWithMultiCapture(t, cfg)
ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
transport.mu.Lock()
defer transport.mu.Unlock()
if len(transport.reqs) != 1 {
t.Fatalf("expected 1 request, got %d", len(transport.reqs))
}
var p map[string]any
json.Unmarshal(transport.bodies[0], &p)
if p["message"] != "Thanks for your comment!" {
t.Errorf("message = %v, want custom message", p["message"])
}
}
func TestSendPrivateReply_APIErrorLoggedAndNonBlocking(t *testing.T) {
// Stateless: API errors are logged (warn) but do not prevent subsequent
// sends. No state to release. Second call still attempts the API.
errorTransport := &captureTransport{
resp: &http.Response{
StatusCode: http.StatusInternalServerError,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("error")),
},
}
cfg := pancakeInstanceConfig{}
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "DM"
msgBus := bus.New()
cfg.PageID = "page-123"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t"}
ch, _ := New(cfg, creds, msgBus, nil)
ch.apiClient.httpClient = &http.Client{Transport: errorTransport}
ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
if errorTransport.req == nil {
t.Fatal("expected first API call to be attempted even when it errors")
}
// Second call: still attempts the API — stateless behaviour.
secondTransport := &captureTransport{}
ch.apiClient.httpClient = &http.Client{Transport: secondTransport}
ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
if secondTransport.req == nil {
t.Error("expected retry request after previous failure (stateless, no per-sender dedup)")
}
}
// TestFactoryExplicitPlatformPreserved verifies that explicit platform from config
// is loaded into the channel and is not overwritten.
func TestFactoryExplicitPlatformPreserved(t *testing.T) {
cfg := json.RawMessage(`{
"page_id": "123",
"platform": "instagram",
"features": {"inbox_reply": true}
}`)
creds := json.RawMessage(`{
"api_key": "test_key",
"page_access_token": "test_token"
}`)
ch, err := Factory("test", creds, cfg, nil, nil)
if err != nil {
t.Fatalf("Factory failed: %v", err)
}
pc := ch.(*Channel)
if pc.platform != "instagram" {
t.Errorf("expected platform instagram from config, got %q", pc.platform)
}
// Verify auto-detect block condition: ch.platform is already set,
// so getPage would NOT be called at Start(). platform should remain "instagram".
// (Start() skips GetPage when ch.platform != "")
if pc.platform == "" {
t.Error("platform must not be empty after Factory with explicit platform config")
}
}
// TestCommentFlowEndToEnd is the Phase 5 integration scenario wired inline.
func TestCommentFlowEndToEnd(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "Welcome!"
transport := &multiCaptureTransport{}
msgBus := bus.New()
cfg.PageID = "page-e2e"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t", WebhookSecret: "test-secret"}
ch, err := New(cfg, creds, msgBus, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
ch.apiClient.httpClient = &http.Client{Transport: transport}
ch.platform = "facebook"
router := &webhookRouter{instances: map[string]*Channel{"page-e2e": ch}}
// Step 1: POST comment webhook.
body := buildWebhookBody("page-e2e", "conv-e2e", "COMMENT", "user-e2e", "msg-e2e", "great product!", "")
req := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body))
signTestPancakeRequest(req, body, ch.webhookSecret)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Step 2: Consume inbound message from bus.
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
inMsg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected inbound message after comment webhook")
}
// Step 3: Verify metadata.
if inMsg.Metadata["pancake_mode"] != "comment" {
t.Errorf("pancake_mode = %q, want comment", inMsg.Metadata["pancake_mode"])
}
if inMsg.Metadata["sender_id"] != "user-e2e" {
t.Errorf("sender_id = %q, want user-e2e", inMsg.Metadata["sender_id"])
}
// Step 4: Send outbound reply.
outMsg := bus.OutboundMessage{
ChatID: inMsg.ChatID,
Content: "thank you!",
Metadata: inMsg.Metadata,
}
if err := ch.Send(context.Background(), outMsg); err != nil {
t.Fatalf("Send error: %v", err)
}
// Step 5: Verify reply_comment + private_reply.
transport.mu.Lock()
reqCount := len(transport.reqs)
var actions []string
for _, b := range transport.bodies {
var p map[string]any
json.Unmarshal(b, &p)
if a, ok := p["action"].(string); ok {
actions = append(actions, a)
}
}
transport.mu.Unlock()
if reqCount != 2 {
t.Fatalf("expected 2 requests (reply_comment + private_reply), got %d (actions: %v)", reqCount, actions)
}
if actions[0] != "reply_comment" {
t.Errorf("first action = %q, want reply_comment", actions[0])
}
if actions[1] != "private_reply" {
t.Errorf("second action = %q, want private_reply", actions[1])
}
// Step 6: Second comment from same sender — stateless: another DM fires.
body2 := buildWebhookBody("page-e2e", "conv-e2e", "COMMENT", "user-e2e", "msg-e2e-2", "another comment", "")
req2 := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body2))
signTestPancakeRequest(req2, body2, ch.webhookSecret)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel2()
inMsg2, ok2 := msgBus.ConsumeInbound(ctx2)
if !ok2 {
t.Fatal("expected second inbound message")
}
outMsg2 := bus.OutboundMessage{
ChatID: inMsg2.ChatID,
Content: "thanks again",
Metadata: inMsg2.Metadata,
}
ch.Send(context.Background(), outMsg2) //nolint:errcheck
transport.mu.Lock()
finalCount := len(transport.reqs)
transport.mu.Unlock()
// 2 (first round: reply_comment + private_reply) + 2 (second: reply_comment + private_reply)
// Stateless — no per-sender dedup. FB's per-comment idempotency handles duplicates platform-side.
if finalCount != 4 {
t.Errorf("expected 4 total requests (stateless: 2 rounds × (reply + DM)), got %d", finalCount)
}
}