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

608 lines
18 KiB
Go

package pancake
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
func capturePancakeSlog(t *testing.T) (*bytes.Buffer, func()) {
t.Helper()
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
prev := slog.Default()
slog.SetDefault(slog.New(handler))
return &buf, func() { slog.SetDefault(prev) }
}
// newTestChannel builds a minimal Channel for handler tests.
// apiSrv may be nil when API calls are not expected.
func newTestChannel(t *testing.T, pageID string, cfg pancakeInstanceConfig) (*Channel, *bus.MessageBus) {
t.Helper()
msgBus := bus.New()
cfg.PageID = pageID
creds := pancakeCreds{
APIKey: "test-key",
PageAccessToken: "test-token",
}
ch, err := New(cfg, creds, msgBus, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
ch.pageName = "Test Page"
ch.platform = "facebook"
ch.stopCtx = context.Background()
return ch, msgBus
}
func newTestChannelWithSrv(t *testing.T, pageID string, cfg pancakeInstanceConfig, srv *httptest.Server) (*Channel, *bus.MessageBus) {
t.Helper()
ch, msgBus := newTestChannel(t, pageID, cfg)
if srv != nil {
ch.apiClient.pageV2BaseURL = srv.URL
ch.apiClient.httpClient = srv.Client()
ch.postFetcher = NewPostFetcher(ch.apiClient, "")
ch.postFetcher.stopCtx = context.Background()
}
return ch, msgBus
}
func commentEvent(pageID, convID, senderID, msgID, content string) MessagingData {
return MessagingData{
PageID: pageID,
ConversationID: convID,
Type: "COMMENT",
Platform: "facebook",
Message: MessagingMessage{
ID: msgID,
SenderID: senderID,
SenderName: "Test User",
Content: content,
},
}
}
// consumeInbound drains one message from the bus with a short timeout.
func consumeInbound(t *testing.T, msgBus *bus.MessageBus, timeout time.Duration) (bus.InboundMessage, bool) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return msgBus.ConsumeInbound(ctx)
}
// --- Feature Gate ---
func TestHandleCommentEvent_FeatureGated(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = false
ch, msgBus := newTestChannel(t, "page-1", cfg)
ch.handleCommentEvent(context.Background(), commentEvent("page-1", "conv-1", "user-1", "msg-1", "hello"))
_, ok := consumeInbound(t, msgBus, 50*time.Millisecond)
if ok {
t.Error("expected no message published when CommentReply is disabled")
}
}
func TestHandleCommentEvent_FeatureDisabledLogsDiagnostic(t *testing.T) {
cfg := pancakeInstanceConfig{}
ch, msgBus := newTestChannel(t, "page-1", cfg)
buf, restore := capturePancakeSlog(t)
defer restore()
ch.handleCommentEvent(context.Background(), commentEvent("page-1", "conv-1", "user-1", "msg-1", "hello"))
ch.handleCommentEvent(context.Background(), commentEvent("page-1", "conv-2", "user-2", "msg-2", "hello again"))
out := buf.String()
if count := strings.Count(out, "comment_reply and auto_react are disabled"); count != 1 {
t.Fatalf("expected exactly one diagnostic log for disabled features, got %d logs:\n%s", count, out)
}
if !strings.Contains(out, "page-1") {
t.Fatalf("expected diagnostic log to include page_id, got:\n%s", out)
}
_, ok := consumeInbound(t, msgBus, 50*time.Millisecond)
if ok {
t.Error("expected no message published when CommentReply is disabled")
}
}
func TestHandleCommentEvent_FeatureEnabled(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
ch, msgBus := newTestChannel(t, "page-1", cfg)
ch.handleCommentEvent(context.Background(), commentEvent("page-1", "conv-1", "user-1", "msg-1", "hello"))
msg, ok := consumeInbound(t, msgBus, 100*time.Millisecond)
if !ok {
t.Fatal("expected message published when CommentReply is enabled")
}
if msg.Metadata["pancake_mode"] != "comment" {
t.Errorf("pancake_mode = %q, want %q", msg.Metadata["pancake_mode"], "comment")
}
}
// --- Self-Reply Prevention ---
func TestHandleCommentEvent_SkipsSelfReply(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
ch, msgBus := newTestChannel(t, "page-1", cfg)
// senderID == pageID: must be skipped without panic
ch.handleCommentEvent(context.Background(), commentEvent("page-1", "conv-1", "page-1", "msg-self", "own reply"))
_, ok := consumeInbound(t, msgBus, 50*time.Millisecond)
if ok {
t.Error("expected self-reply to be dropped")
}
}
func TestHandleCommentEvent_SkipsAssignedStaff(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
ch, msgBus := newTestChannel(t, "page-1", cfg)
data := commentEvent("page-1", "conv-1", "staff-1", "msg-staff", "staff comment")
data.AssigneeIDs = []string{"staff-1"}
ch.handleCommentEvent(context.Background(), data)
_, ok := consumeInbound(t, msgBus, 50*time.Millisecond)
if ok {
t.Error("expected assigned staff message to be dropped")
}
}
// --- Dedup ---
func TestHandleCommentEvent_DedupDropsSecondCall(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
ch, msgBus := newTestChannel(t, "page-1", cfg)
evt := commentEvent("page-1", "conv-1", "user-1", "msg-dup", "hello")
ch.handleCommentEvent(context.Background(), evt)
ch.handleCommentEvent(context.Background(), evt)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, ok1 := msgBus.ConsumeInbound(ctx)
if !ok1 {
t.Fatal("expected first message to be published")
}
_, ok2 := consumeInbound(t, msgBus, 30*time.Millisecond)
if ok2 {
t.Error("expected second duplicate to be dropped")
}
}
func TestHandleCommentEvent_EmptyMsgIDNotDeduped(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
ch, msgBus := newTestChannel(t, "page-1", cfg)
ch.handleCommentEvent(context.Background(), commentEvent("page-1", "conv-1", "user-1", "", "msg A"))
ch.handleCommentEvent(context.Background(), commentEvent("page-1", "conv-1", "user-1", "", "msg B"))
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")
}
}
// --- Comment Filter ---
func TestFilterComment_AllMode(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.CommentReplyOptions.Filter = "all"
ch, _ := newTestChannel(t, "page-1", cfg)
if !ch.filterComment("random text") {
t.Error("filter=all should pass all comments")
}
}
func TestFilterComment_KeywordMatch(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.CommentReplyOptions.Filter = "keyword"
cfg.CommentReplyOptions.Keywords = []string{"price", "buy"}
ch, _ := newTestChannel(t, "page-1", cfg)
if !ch.filterComment("what is the price?") {
t.Error("expected 'price' keyword to match")
}
if !ch.filterComment("I want to buy this") {
t.Error("expected 'buy' keyword to match")
}
if ch.filterComment("nice photo") {
t.Error("expected 'nice photo' to be filtered out")
}
}
func TestFilterComment_KeywordCaseInsensitive(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.CommentReplyOptions.Filter = "keyword"
cfg.CommentReplyOptions.Keywords = []string{"Price"}
ch, _ := newTestChannel(t, "page-1", cfg)
if !ch.filterComment("what is the PRICE?") {
t.Error("keyword match should be case-insensitive")
}
}
func TestFilterComment_EmptyFilter(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.CommentReplyOptions.Filter = ""
ch, _ := newTestChannel(t, "page-1", cfg)
if !ch.filterComment("anything") {
t.Error("empty filter should default to all (pass everything)")
}
}
// --- Content Enrichment ---
func TestBuildEnrichedContent_WithPostContext(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
cfg.CommentReplyOptions.IncludePostContext = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"data": []PancakePost{{ID: "post-abc", Message: "Hello world post"}},
})
}))
defer srv.Close()
ch, _ := newTestChannelWithSrv(t, "page-1", cfg, srv)
data := commentEvent("page-1", "conv-1", "user-1", "msg-1", "question about this")
data.PostID = "post-abc"
content := ch.buildCommentContent(data)
if !contains(content, "[Bai dang]") {
t.Errorf("expected [Bai dang] prefix in enriched content, got: %s", content)
}
if !contains(content, "Hello world post") {
t.Errorf("expected post message in enriched content, got: %s", content)
}
if !contains(content, "[Comment moi]") {
t.Errorf("expected [Comment moi] in enriched content, got: %s", content)
}
if !contains(content, "question about this") {
t.Errorf("expected comment text in enriched content, got: %s", content)
}
}
func TestBuildEnrichedContent_WithoutPostContext(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.CommentReplyOptions.IncludePostContext = false
ch, _ := newTestChannel(t, "page-1", cfg)
data := commentEvent("page-1", "conv-1", "user-1", "msg-1", "just a comment")
data.PostID = "post-abc"
content := ch.buildCommentContent(data)
if contains(content, "[Bai dang]") {
t.Errorf("should not include post context when IncludePostContext=false, got: %s", content)
}
if !contains(content, "just a comment") {
t.Errorf("expected comment text in output, got: %s", content)
}
}
func TestBuildEnrichedContent_PostFetchFails(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.CommentReplyOptions.IncludePostContext = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
ch, _ := newTestChannelWithSrv(t, "page-1", cfg, srv)
data := commentEvent("page-1", "conv-1", "user-1", "msg-1", "comment text")
data.PostID = "post-fail"
content := ch.buildCommentContent(data)
// Should fall back to comment text only — no panic, no error propagated.
if content == "" {
t.Error("expected non-empty content on post fetch failure")
}
if contains(content, "[Bai dang]") {
t.Errorf("should not include post prefix when fetch fails, got: %s", content)
}
}
// --- Metadata ---
func TestHandleCommentEvent_MetadataFields(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
ch, msgBus := newTestChannel(t, "page-1", cfg)
ch.platform = "facebook"
data := MessagingData{
PageID: "page-1",
ConversationID: "conv-abc",
Type: "COMMENT",
Platform: "facebook",
Message: MessagingMessage{
ID: "msg-xyz",
SenderID: "user-1",
SenderName: "John Doe",
Content: "test comment",
},
}
ch.handleCommentEvent(context.Background(), data)
msg, ok := consumeInbound(t, msgBus, 100*time.Millisecond)
if !ok {
t.Fatal("expected message published")
}
checks := map[string]string{
"pancake_mode": "comment",
"conversation_type": "COMMENT",
"reply_to_comment_id": "msg-xyz",
"sender_id": "user-1",
"platform": "facebook",
"page_name": "Test Page",
"conversation_id": "conv-abc",
}
for key, want := range checks {
if got := msg.Metadata[key]; got != want {
t.Errorf("metadata[%q] = %q, want %q", key, got, want)
}
}
}
// --- Session Key ---
func TestHandleCommentEvent_ChatIDIsConversationID(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
ch, msgBus := newTestChannel(t, "page-1", cfg)
ch.handleCommentEvent(context.Background(), commentEvent("page-1", "conv-distinct", "user-1", "msg-1", "hello"))
msg, ok := consumeInbound(t, msgBus, 100*time.Millisecond)
if !ok {
t.Fatal("expected message published")
}
if msg.ChatID != "conv-distinct" {
t.Errorf("ChatID = %q, want %q", msg.ChatID, "conv-distinct")
}
}
// contains is a helper to check if a string contains a substring.
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
// --- AutoReact ---
func TestHandleCommentEvent_AutoReactEnabled(t *testing.T) {
done := make(chan string, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/likes") {
parts := strings.Split(r.URL.Path, "/")
for i, p := range parts {
if p == "likes" && i > 0 {
select {
case done <- parts[i-1]:
default:
}
}
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"success":true}`))
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
cfg.Features.AutoReact = true
ch, _ := newTestChannel(t, "page-1", cfg)
ch.apiClient.userBaseURL = srv.URL
ch.apiClient.httpClient = srv.Client()
evt := commentEvent("page-1", "conv-abc", "user-1", "msg-xyz", "hello page!")
ch.handleCommentEvent(context.Background(), evt)
select {
case gotID := <-done:
if gotID != "msg-xyz" {
t.Errorf("expected message ID msg-xyz in path, got %q", gotID)
}
case <-time.After(2 * time.Second):
t.Fatal("ReactComment was not called within 2s")
}
}
func TestHandleCommentEvent_AutoReactDisabled(t *testing.T) {
reacted := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/likes") {
select {
case reacted <- struct{}{}:
default:
}
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
ch, _ := newTestChannel(t, "page-1", cfg)
ch.apiClient.userBaseURL = srv.URL
ch.apiClient.httpClient = srv.Client()
evt := commentEvent("page-1", "conv-1", "user-1", "123456789012345", "test comment")
ch.handleCommentEvent(context.Background(), evt)
time.Sleep(100 * time.Millisecond)
select {
case <-reacted:
t.Error("ReactComment must NOT be called when AutoReact=false")
default:
}
}
func TestHandleCommentEvent_AutoReact_IndependentOfCommentReply(t *testing.T) {
done := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/likes") {
select {
case done <- struct{}{}:
default:
}
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = false
cfg.Features.AutoReact = true
ch, _ := newTestChannel(t, "page-1", cfg)
ch.apiClient.userBaseURL = srv.URL
ch.apiClient.httpClient = srv.Client()
evt := commentEvent("page-1", "conv-1", "user-1", "123456789012345", "hi!")
ch.handleCommentEvent(context.Background(), evt)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("AutoReact must fire even when CommentReply=false")
}
}
func TestHandleCommentEvent_AutoReact_SkipNonFacebook(t *testing.T) {
reacted := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/likes") {
select {
case reacted <- struct{}{}:
default:
}
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := pancakeInstanceConfig{}
cfg.Features.AutoReact = true
cfg.Features.CommentReply = true
ch, _ := newTestChannel(t, "page-1", cfg)
ch.platform = "instagram"
ch.apiClient.userBaseURL = srv.URL
ch.apiClient.httpClient = srv.Client()
evt := commentEvent("page-1", "conv-1", "user-1", "123456789012345", "hi!")
evt.Platform = "instagram"
ch.handleCommentEvent(context.Background(), evt)
time.Sleep(100 * time.Millisecond)
select {
case <-reacted:
t.Error("ReactComment must NOT be called for non-Facebook platforms")
default:
}
}
func TestHandleCommentEvent_AutoReact_EmptyMessageID(t *testing.T) {
reacted := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/likes") {
select {
case reacted <- struct{}{}:
default:
}
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := pancakeInstanceConfig{}
cfg.Features.AutoReact = true
cfg.Features.CommentReply = true
ch, _ := newTestChannel(t, "page-1", cfg)
ch.apiClient.userBaseURL = srv.URL
ch.apiClient.httpClient = srv.Client()
// Empty message ID → must not react (guards against malformed webhook)
evt := commentEvent("page-1", "conv-1", "user-1", "", "hi!")
ch.handleCommentEvent(context.Background(), evt)
time.Sleep(100 * time.Millisecond)
select {
case <-reacted:
t.Error("ReactComment must NOT fire when message ID is empty")
default:
}
}
func TestFilterAutoReact_Matrix(t *testing.T) {
tests := []struct {
name string
opts *AutoReactOptions
postID, senderID string
want bool
}{
{"nil opts → allow", nil, "p1", "u1", true},
{"empty opts → allow", &AutoReactOptions{}, "p1", "u1", true},
{"deny user match", &AutoReactOptions{DenyUserIDs: []string{"u1"}}, "p1", "u1", false},
{"deny post match", &AutoReactOptions{DenyPostIDs: []string{"p1"}}, "p1", "u1", false},
{"allow user miss (list non-empty)", &AutoReactOptions{AllowUserIDs: []string{"u2"}}, "p1", "u1", false},
{"allow user hit", &AutoReactOptions{AllowUserIDs: []string{"u1"}}, "p1", "u1", true},
{"allow post miss", &AutoReactOptions{AllowPostIDs: []string{"p2"}}, "p1", "u1", false},
{"allow post hit", &AutoReactOptions{AllowPostIDs: []string{"p1"}}, "p1", "u1", true},
{"deny beats allow (overlap)", &AutoReactOptions{
AllowUserIDs: []string{"u1"},
DenyUserIDs: []string{"u1"},
}, "p1", "u1", false},
{"deny user with whitespace trims", &AutoReactOptions{DenyUserIDs: []string{" u1 "}}, "p1", "u1", false},
{"allow post with whitespace trims to match", &AutoReactOptions{AllowPostIDs: []string{" p1 "}}, "p1", "u1", true},
{"empty senderID with deny empty-string → no match", &AutoReactOptions{DenyUserIDs: []string{""}}, "p1", "", true},
{"empty sender + allow list → blocked", &AutoReactOptions{AllowUserIDs: []string{"u1"}}, "p1", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &pancakeInstanceConfig{AutoReactOptions: tt.opts}
got := filterAutoReact(cfg, tt.postID, tt.senderID)
if got != tt.want {
t.Errorf("filterAutoReact() = %v, want %v", got, tt.want)
}
})
}
}