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) } }) } }