mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 06:16:10 +00:00
* feat(pancake): private-reply funnel — 2 modes, scope filter, DB dedup, metrics
Refactor the pancake channel's one-time DM feature from the 5-day-old minimal
`first_inbox` toggle into a complete comment → DM funnel. Breaking rename
(first_inbox → private_reply) matches Pancake + Facebook Graph terminology.
Channel logic
- Two modes: `after_reply` (default — public reply then DM) and `standalone`
(DM-only; bypasses keyword filter, publishes synthetic outbound via
`ch.Bus().PublishOutbound` so the LLM pipeline is skipped for template DMs).
- Post-level scope filter (`private_reply_options.allow_post_ids` /
`deny_post_ids`, deny beats allow).
- Template vars `{{commenter_name}}` / `{{post_title}}` with pre-sanitized
literal-replace (strips `{{`/`}}` from values — no nested substitution).
- Locale-aware default text via `i18n.T(locale, MsgPancakePrivateReplyDefault)`
with en/vi/zh catalogs and English fallback.
DB dedup (replaces in-memory sync.Map)
- New `store.PancakePrivateReplyStore` (PG + SQLite impls), tenant-scoped via
`store.TenantIDFromContext`, fail-closed on missing tenant.
- Atomic `TryClaim` / `Unclaim` (INSERT ... ON CONFLICT DO UPDATE WHERE stale)
eliminates the concurrent-comment TOCTOU that would have fired duplicate
DMs — claim first, release on API failure so the next comment can retry.
- Configurable TTL (`private_reply_ttl_days`, default 7).
- Dual-DB: migration 000056 on PG; schema v25 on SQLite.
Bumps RequiredSchemaVersion 55 → 56.
Wiring
- `pancake.FactoryWithStores(ppReplyStore)` follows the existing
`FactoryWithStores*` pattern (telegram, discord, whatsapp) and replaces
`pancake.Factory` in `cmd/gateway.go`.
- `Channel.Send` injects `store.WithTenantID(ctx, ch.TenantID())` before
dispatch so the outbound path sees a non-nil tenant (regression guard:
without this the new store fails-closed and drops DMs in production — unit
fakes masked this by bypassing the tenant check).
- Routing metadata whitelist extended: `private_reply_mode`,
`private_reply_only`, `post_id`, `display_name` survive inbound → outbound.
Observability
- New `internal/metrics` package with `pancake_private_reply_total{
page_id, result, reason}` counter and `/metrics` endpoint mounted on the
gateway.
- Endpoint gated behind Bearer auth when `gateway.token` is configured; open
(with warning) when unset for local dev scraping. Operators should set a
token or bind `GOCLAW_HOST=127.0.0.1` to avoid leaking page identifiers.
UI
- 6 additive Pancake fields in `channel-schemas.ts` with `showWhen` gating on
`features.private_reply` (itself gated on platform = fb/ig).
- 10 schema tests; full web build passes.
- i18n entries in en/vi/zh `channels.json` + 2 new Go keys
(`MsgPancakePrivateReplyWindowExpired`, `MsgPancakePrivateReplyDefault`).
Tests
- 18 unit tests in pancake package (render/filter/mode switch/scope/dedup/
metrics/tenant-ctx regression); 6 store-level tests; 5 E2E tests against
live PG covering happy path, dedup-across-restart, TTL expiry via SQL,
scope filter, FB 7-day policy error → no claim retained.
- All green on both PG and SQLite build tags with race detector.
* refactor(pancake): simplify private-reply to stateless DM
Roll back the dedup funnel from PR 951 — no table, no in-memory state,
no metrics, no modes, no scope filter. Keep only feature flag + message
template.
Rationale: each platform owning its own dedup table is an anti-pattern
that inflates multi-tenant data footprint. Use the platform itself
(Facebook Graph /comment/private_replies is per-comment idempotent)
combined with the existing webhook comment_id dedup in
comment_handler.go. Zero GoClaw state required.
Removed:
- DB table pancake_private_reply_sent (PG migration 000056, SQLite v25)
- Store interface + PG + SQLite implementations + tests
- internal/metrics package + /metrics endpoint + prometheus dep
- Two modes (after_reply/standalone) + standalone fast-path
- Scope filter (allow/deny post IDs) + PrivateReplyOptions struct
- TTL config (private_reply_ttl_days)
- Locale-aware i18n default (MsgPancakePrivateReplyDefault,
MsgPancakePrivateReplyWindowExpired)
- FactoryWithStores wiring + tenant ctx injection in Send()
- SetHTTPClientForTest dead code
Kept:
- features.private_reply flag
- private_reply_message template with {{commenter_name}} and
{{post_title}} vars (literal-replace, injection-safe)
- After-reply flow: comment -> public reply -> DM
- Hard-coded English fallback when message is empty
Schema: RequiredSchemaVersion 56 -> 55; SQLite SchemaVersion 25 -> 24;
schema.sql DDL block removed.
Net delta: -2661 lines across 44 files. Build (PG + sqliteonly) +
vet + race tests clean.
* test(agent): bump none-mode prompt size budget to 3100
vault_read wiring (#948) added ~95 chars to read_file tool summary,
pushing none-mode prompt from <3000 to 3075 chars. Bump budget to
3100 (~775 tokens) to match the intentional addition.
* test(integration): ensure data_migrations table exists in reset helper
The reset helper runs before RunPendingHooks, but RunPendingHooks is
what normally creates data_migrations. On a fresh CI database the
DELETE fails with 'relation does not exist'. Create the table
defensively so reset works regardless of execution order.
* chore(sqlite): remove accidental trailing blank line in schema.sql
---------
Co-authored-by: viettranx <viettranx@gmail.com>
608 lines
18 KiB
Go
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(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(commentEvent("page-1", "conv-1", "user-1", "msg-1", "hello"))
|
|
ch.handleCommentEvent(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(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(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(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(evt)
|
|
ch.handleCommentEvent(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(commentEvent("page-1", "conv-1", "user-1", "", "msg A"))
|
|
ch.handleCommentEvent(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(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(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(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(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(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(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(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)
|
|
}
|
|
})
|
|
}
|
|
}
|