Files
Plateau Nguyenandviettranx 8dc101e425 feat(pancake): add Shopee platform support (#975)
* feat(pancake): add Shopee platform support

- Fix Accept header for JSON response negotiation (Shopee GETs)
- Platform-prefix-aware pageID parsing (spo_ convID format)
- 500-char message limit for Shopee DMs
- Rune-safe truncation with slog.Warn on truncation

Phase 3 (integration smoke) pending: requires real credentials.

* fix(pancake): address PR review feedback

- Add RegisterPlatformPrefix() for extensibility (issue #1)
- Rename setCommonHeaders → setAcceptJSONHeader (issue #2)
- Add testdata fixture with page_id for priority testing (issue #3)

* chore: retrigger CI

* fix(permissions): classify sessions.compact in RBAC policy

Add MethodSessionsCompact to isWriteMethod to fix drift coverage test.
Method was added in #958 but missing from permissions classification.

* fix(tests): use strings.Contains to avoid redeclaration

Remove duplicate contains helper in tts_gemini_live_test.go that
conflicts with mcp_grant_revoke_test.go in same package.

* refactor(pancake): guard platformPrefixes with RWMutex for concurrent safety

RegisterPlatformPrefix is exported for future use (Lazada, Tokopedia) but
webhook handler reads the map concurrently. Protect with sync.RWMutex so
callers can register prefixes at any time without data races.

- Add platformPrefixesMu sync.RWMutex
- Wrap RegisterPlatformPrefix with write lock
- Extract isKnownPlatformPrefix helper with read lock
- Document that RegisterPlatformPrefix is currently unused (extension point)

---------

Co-authored-by: viettranx <viettranx@gmail.com>
2026-04-24 08:37:40 +07:00

419 lines
14 KiB
Go

package pancake
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// --- ReplyComment ---
func TestReplyComment_MatchesOfficialContract(t *testing.T) {
transport := &captureTransport{}
client := NewAPIClient("user-token", "page-token", "page-123")
client.httpClient = &http.Client{Transport: transport}
if err := client.ReplyComment(context.Background(), "conv-123", "msg-456", "thank you"); err != nil {
t.Fatalf("ReplyComment returned error: %v", err)
}
if transport.req == nil {
t.Fatal("expected outbound request to be captured")
}
wantPath := "/api/public_api/v1/pages/page-123/conversations/conv-123/messages"
if got := transport.req.URL.Path; got != wantPath {
t.Fatalf("request path = %q, want %q", got, wantPath)
}
if got := transport.req.URL.Query().Get("page_access_token"); got != "page-token" {
t.Fatalf("page_access_token = %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_comment"; got != want {
t.Fatalf("payload.action = %#v, want %#v", got, want)
}
if got, want := payload["message"], "thank you"; got != want {
t.Fatalf("payload.message = %#v, want %#v", got, want)
}
if got, want := payload["message_id"], "msg-456"; got != want {
t.Fatalf("payload.message_id = %#v, want %#v", got, want)
}
}
func TestReplyComment_ReturnsError(t *testing.T) {
transport := &captureTransport{
resp: &http.Response{
StatusCode: http.StatusBadRequest,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"code":400,"message":"bad request"}`)),
},
}
client := NewAPIClient("user-token", "page-token", "page-123")
client.httpClient = &http.Client{Transport: transport}
if err := client.ReplyComment(context.Background(), "conv-123", "msg-456", "thank you"); err == nil {
t.Fatal("expected ReplyComment to return error on HTTP 400")
}
}
// --- PrivateReply ---
func TestPrivateReply_MatchesOfficialContract(t *testing.T) {
transport := &captureTransport{}
client := NewAPIClient("user-token", "page-token", "page-123")
client.httpClient = &http.Client{Transport: transport}
if err := client.PrivateReply(context.Background(), "conv-123", "thanks for commenting!"); err != nil {
t.Fatalf("PrivateReply returned error: %v", err)
}
if transport.req == nil {
t.Fatal("expected outbound request to be captured")
}
wantPath := "/api/public_api/v1/pages/page-123/conversations/conv-123/messages"
if got := transport.req.URL.Path; got != wantPath {
t.Fatalf("request path = %q, want %q", got, wantPath)
}
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"], "private_reply"; got != want {
t.Fatalf("payload.action = %#v, want %#v", got, want)
}
if got, want := payload["message"], "thanks for commenting!"; got != want {
t.Fatalf("payload.message = %#v, want %#v", got, want)
}
}
func TestPrivateReply_ReturnsError(t *testing.T) {
transport := &captureTransport{
resp: &http.Response{
StatusCode: http.StatusBadRequest,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"code":400,"message":"conversation not found"}`)),
},
}
client := NewAPIClient("user-token", "page-token", "page-123")
client.httpClient = &http.Client{Transport: transport}
if err := client.PrivateReply(context.Background(), "conv-123", "hi"); err == nil {
t.Fatal("expected PrivateReply to return error on HTTP 400")
}
}
// --- GetPosts ---
func TestGetPosts_ParsesResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true,"data":[{"id":"post-1","message":"Hello world","created_at":"2024-01-01"}]}`))
}))
defer srv.Close()
client := NewAPIClient("user-token", "page-token", "page-123")
client.pageV2BaseURL = srv.URL
client.httpClient = srv.Client()
posts, err := client.GetPosts(context.Background(), 10)
if err != nil {
t.Fatalf("GetPosts returned error: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post, got %d", len(posts))
}
if posts[0].ID != "post-1" {
t.Errorf("posts[0].ID = %q, want %q", posts[0].ID, "post-1")
}
if posts[0].Message != "Hello world" {
t.Errorf("posts[0].Message = %q, want %q", posts[0].Message, "Hello world")
}
}
func TestGetPosts_EmptyResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"success":true,"data":[]}`))
}))
defer srv.Close()
client := NewAPIClient("user-token", "page-token", "page-123")
client.pageV2BaseURL = srv.URL
client.httpClient = srv.Client()
posts, err := client.GetPosts(context.Background(), 10)
if err != nil {
t.Fatalf("GetPosts returned error: %v", err)
}
if len(posts) != 0 {
t.Fatalf("expected 0 posts, got %d", len(posts))
}
}
func TestGetPosts_ErrorResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`internal error`))
}))
defer srv.Close()
client := NewAPIClient("user-token", "page-token", "page-123")
client.pageV2BaseURL = srv.URL
client.httpClient = srv.Client()
_, err := client.GetPosts(context.Background(), 10)
if err == nil {
t.Fatal("expected error on HTTP 500")
}
}
// --- Config Parsing ---
func TestConfigParsing_CommentReplyOptions(t *testing.T) {
raw := `{
"page_id": "123",
"features": {"comment_reply": true, "private_reply": true},
"comment_reply_options": {"filter": "keyword", "keywords": ["price", "buy"]},
"private_reply_message": "Thanks!",
"post_context_cache_ttl": "30m"
}`
var cfg pancakeInstanceConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !cfg.Features.PrivateReply {
t.Error("Features.PrivateReply should be true")
}
if cfg.CommentReplyOptions.Filter != "keyword" {
t.Errorf("Filter = %q, want %q", cfg.CommentReplyOptions.Filter, "keyword")
}
if len(cfg.CommentReplyOptions.Keywords) != 2 ||
cfg.CommentReplyOptions.Keywords[0] != "price" ||
cfg.CommentReplyOptions.Keywords[1] != "buy" {
t.Errorf("Keywords = %v, want [price buy]", cfg.CommentReplyOptions.Keywords)
}
if cfg.PrivateReplyMessage != "Thanks!" {
t.Errorf("PrivateReplyMessage = %q, want %q", cfg.PrivateReplyMessage, "Thanks!")
}
if cfg.PostContextCacheTTL != "30m" {
t.Errorf("PostContextCacheTTL = %q, want %q", cfg.PostContextCacheTTL, "30m")
}
}
func TestPancakeConfig_AutoReactOptionsRoundtrip(t *testing.T) {
src := `{
"page_id": "123",
"platform": "facebook",
"features": {"auto_react": true},
"auto_react_options": {
"allow_post_ids": ["post-1", "post-2"],
"deny_post_ids": ["post-3"],
"allow_user_ids": ["user-a"],
"deny_user_ids": ["user-b", "user-c"]
}
}`
var cfg pancakeInstanceConfig
if err := json.Unmarshal([]byte(src), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.AutoReactOptions == nil {
t.Fatal("AutoReactOptions should be non-nil when JSON has auto_react_options")
}
if len(cfg.AutoReactOptions.AllowPostIDs) != 2 {
t.Errorf("AllowPostIDs len = %d, want 2", len(cfg.AutoReactOptions.AllowPostIDs))
}
if cfg.AutoReactOptions.DenyPostIDs[0] != "post-3" {
t.Errorf("DenyPostIDs[0] = %q, want post-3", cfg.AutoReactOptions.DenyPostIDs[0])
}
if len(cfg.AutoReactOptions.DenyUserIDs) != 2 {
t.Errorf("DenyUserIDs len = %d, want 2", len(cfg.AutoReactOptions.DenyUserIDs))
}
if cfg.AutoReactOptions.AllowUserIDs[0] != "user-a" {
t.Errorf("AllowUserIDs[0] = %q, want user-a", cfg.AutoReactOptions.AllowUserIDs[0])
}
}
func TestPancakeConfig_AutoReactOptionsOmitempty(t *testing.T) {
var cfg pancakeInstanceConfig
cfg.PageID = "123"
b, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(b), "auto_react_options") {
t.Errorf("empty AutoReactOptions should be omitted, got: %s", b)
}
}
func TestPancakeConfig_AutoReactOptionsDefaultsEmpty(t *testing.T) {
src := `{"page_id": "123"}`
var cfg pancakeInstanceConfig
if err := json.Unmarshal([]byte(src), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.AutoReactOptions != nil {
t.Errorf("AutoReactOptions should be nil when absent, got %+v", cfg.AutoReactOptions)
}
}
func TestConfigParsing_Defaults(t *testing.T) {
raw := `{"page_id":"123","features":{"inbox_reply":true}}`
var cfg pancakeInstanceConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.Features.PrivateReply {
t.Error("Features.PrivateReply should default to false")
}
if cfg.CommentReplyOptions.Filter != "" {
t.Errorf("CommentReplyOptions.Filter should default to empty, got %q", cfg.CommentReplyOptions.Filter)
}
if cfg.PrivateReplyMessage != "" {
t.Errorf("PrivateReplyMessage should default to empty, got %q", cfg.PrivateReplyMessage)
}
}
// --- ReactComment (Pancake user API) ---
func TestReactComment_Success(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") {
if got := r.URL.Query().Get("access_token"); got != "user-key" {
t.Errorf("access_token query = %q, want user-key", got)
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
t.Errorf("Content-Type = %q, want multipart/form-data", ct)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("ParseMultipartForm: %v", err)
}
if got := r.FormValue("action"); got != "like_toggle" {
t.Errorf("action = %q, want like_toggle", got)
}
if got := r.FormValue("user_likes"); got != "false" {
t.Errorf("user_likes = %q, want false", got)
}
select {
case done <- r.URL.Path:
default:
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"success":true}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
client := NewAPIClient("user-key", "page-token", "page123")
client.userBaseURL = srv.URL
client.httpClient = srv.Client()
err := client.ReactComment(context.Background(), "conv-1_msg-1", "conv-1_msg-1")
if err != nil {
t.Fatalf("ReactComment unexpected error: %v", err)
}
select {
case path := <-done:
want := "/pages/page123/conversations/conv-1_msg-1/messages/conv-1_msg-1/likes"
if path != want {
t.Errorf("path = %q, want %q", path, want)
}
case <-time.After(2 * time.Second):
t.Fatal("POST /likes was not called within 2s")
}
}
func TestReactComment_ErrorIncludesBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"success":false,"message":"invalid access_token"}`))
}))
defer srv.Close()
client := NewAPIClient("bad-key", "page-token", "page123")
client.userBaseURL = srv.URL
client.httpClient = srv.Client()
err := client.ReactComment(context.Background(), "conv-1", "msg-1")
if err == nil {
t.Fatal("expected error for 401 response")
}
if !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "invalid access_token") {
t.Errorf("expected HTTP status + body in error, got: %v", err)
}
}
func TestReactComment_RejectsInvalidIDs(t *testing.T) {
client := NewAPIClient("key", "token", "page123")
cases := []struct{ conv, msg string }{
{"", "msg-1"},
{"conv-1", ""},
{"conv/1", "msg-1"},
{"conv-1", "msg?evil=1"},
{"conv#frag", "msg-1"},
}
for _, c := range cases {
if err := client.ReactComment(context.Background(), c.conv, c.msg); err == nil {
t.Errorf("expected error for conv=%q msg=%q, got nil", c.conv, c.msg)
}
}
}
// --- Accept Header Tests (Shopee support) ---
// TestNewPageRequest_SetsAcceptJSONHeader verifies the Pancake GET negotiation fix:
// without Accept: application/json, Pancake returns SPA HTML for Shopee endpoints.
func TestNewPageRequest_SetsAcceptJSONHeader(t *testing.T) {
client := NewAPIClient("user-token", "page-token", "spo_25409726")
req, err := client.newPageRequest(context.Background(), http.MethodGet,
"https://pages.fm/api/public_api/v2/pages/spo_25409726/conversations", nil)
if err != nil {
t.Fatalf("newPageRequest: %v", err)
}
if got := req.Header.Get("Accept"); got != "application/json" {
t.Fatalf("Accept header = %q, want %q", got, "application/json")
}
if got := req.Header.Get("Authorization"); got != "Bearer page-token" {
t.Fatalf("Authorization header = %q, want %q", got, "Bearer page-token")
}
}
// TestGetPage_SetsAcceptJSONHeader — C2 guard. GetPage bypasses newPageRequest
// (it builds its own http.NewRequestWithContext for the user-API /pages endpoint).
// Without this header, startup auto-detect receives SPA HTML for Shopee pages.
func TestGetPage_SetsAcceptJSONHeader(t *testing.T) {
transport := &captureTransport{
resp: &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
},
}
client := NewAPIClient("user-token", "page-token", "spo_25409726")
client.httpClient = &http.Client{Transport: transport}
if _, err := client.GetPage(context.Background()); err != nil {
t.Fatalf("GetPage: %v", err)
}
if transport.req == nil {
t.Fatal("expected request to be captured")
}
if got := transport.req.Header.Get("Accept"); got != "application/json" {
t.Fatalf("Accept header on GetPage = %q, want %q", got, "application/json")
}
}