mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 08:15:12 +00:00
test(facebook,store/pg): wave B coverage push
Phase 03 - facebook 23.1% -> 81.9% (+58.8%, target ≥60%) graph_api_test.go: NewGraphClient, VerifyToken, SubscribeApp, GetPost, GetComment, GetCommentThread, ReplyToComment, SendMessage, SendTypingOn, doRequest retry/backoff matrix (5xx retry, 401 non-retry, 429+Retry-After, context cancel during backoff, transport error + retry), 551/subcode 24h window non-retry, logRateLimit parse branches. handlers_test.go: New required-field validation (4 branches), Factory valid+malformed, WebhookHandler single-shot route, handleAPIError health state mapping, handleCommentEvent (feature gate, edit/remove drop, page routing, self-reply skip, dedup, enriched content path), handleMessagingEvent (feature gate, page routing, self skip, receipt drop, text+postback dispatch, dedup), Send Messenger+comment paths + missing-metadata error, sendFirstInbox one-shot dedup, runDedupCleaner stale eviction, Stop closes deps, webhookRouter register/unregister/route-once/ServeHTTP-no-instances, PostFetcher (defaults, empty-id, cache hit/miss/expired, error propagation, GetCommentThread empty+delegate). Minimal refactor: converted package-level graphAPIBase const to var so tests can point at httptest server. KISS, zero runtime behavior change. Phase 04 - store/pg 1.3% -> 3.5% (+2.2%, unit-test-only) scan_rows_test.go: pure-logic transformation tests for agentShareRow, userInstanceRow, documentInfoRow, documentDetailRow, chunkInfoRow, scoredChunkRow, episodicSummaryRow, episodicScoredRow, sessionListRow, sessionPagedRow, sessionRichRow, entityRow, entityTemporalRow, relationRow, relationExportRow, traversalRow, dedupCandidateRow, mcpAccessRequestRow, cronRunLogRow, skillInfoRow+skillInfoRowWithFrontmatter, customSkillExportRow, parseDepsColumn, parseFrontmatterAuthor, marshalFrontmatter, buildSkillInfo, mergeEpisodicScores, hybridMerge. pure_logic_test.go: matchWildcard pattern matching, evalPermRows priority resolution (individual deny > individual allow > group deny > group allow), contactResolveCache get/set/expired/negative cache. Finding: 30% target not reachable with unit tests alone. Store/pg is fundamentally DB-bound (~6000 LOC of SQL code vs ~200 LOC pure logic). Reaching 30% requires running integration tests with TEST_DATABASE_URL and -coverpkg=./internal/store/pg/ — which the current CI ratchet does NOT do. Confirmed: integration tests yield 32% coverage on store/pg, but that requires a wired-up test database in CI. Deferred as infrastructure change outside Phase 04 scope. Ratchet bumps (scripts/coverage_thresholds.json): - internal/channels/facebook: 23.08 -> 81.80 - internal/store/pg: 1.28 -> 3.45 Race clean, dual build (default + -tags sqliteonly) clean.
This commit is contained in:
1 parent
20de0e332e
commit
f2c43c3849
6 files changed
+2230
-3
No files matched your search
@@ -16,12 +16,15 @@ import (
|
||||
|
||||
const (
|
||||
graphAPIVersion = "v25.0"
|
||||
graphAPIBase = "https://graph.facebook.com"
|
||||
maxRetries = 3
|
||||
// maxRetryAfterSec caps the Retry-After sleep to prevent goroutine stalls on abnormal values.
|
||||
maxRetryAfterSec = 60
|
||||
)
|
||||
|
||||
// graphAPIBase is the Graph API root. Declared as a variable so tests can
|
||||
// override it with an httptest.NewServer URL.
|
||||
var graphAPIBase = "https://graph.facebook.com"
|
||||
|
||||
// fbIDPattern validates Facebook object IDs: numeric or "{num}_{num}" form (post IDs).
|
||||
var fbIDPattern = regexp.MustCompile(`^\d+(_\d+)?$`)
|
||||
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
package facebook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// swapGraphBase points graphAPIBase at a test server for the duration of t.
|
||||
func swapGraphBase(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
orig := graphAPIBase
|
||||
graphAPIBase = url
|
||||
t.Cleanup(func() { graphAPIBase = orig })
|
||||
}
|
||||
|
||||
// newFakeGraph spins up a test server and returns (client pointed at it, server).
|
||||
func newFakeGraph(t *testing.T, handler http.Handler) *GraphClient {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
return NewGraphClient("fake-token", "111222333")
|
||||
}
|
||||
|
||||
// TestParseRetryAfterNegativeDefaults extends the existing TestParseRetryAfter
|
||||
// in facebook_test.go with the negative-value branch (defaults to 5s).
|
||||
func TestParseRetryAfterNegativeDefaults(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Retry-After", "-5")
|
||||
got := parseRetryAfter(&http.Response{Header: h})
|
||||
if got != 5*time.Second {
|
||||
t.Errorf("got %v, want 5s (negative Retry-After should default)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- NewGraphClient ---
|
||||
|
||||
func TestNewGraphClient_ConstructsWithTokenAndPage(t *testing.T) {
|
||||
g := NewGraphClient("tok", "111")
|
||||
if g.pageAccessToken != "tok" {
|
||||
t.Errorf("pageAccessToken = %q, want tok", g.pageAccessToken)
|
||||
}
|
||||
if g.pageID != "111" {
|
||||
t.Errorf("pageID = %q, want 111", g.pageID)
|
||||
}
|
||||
if g.httpClient == nil {
|
||||
t.Error("httpClient is nil")
|
||||
}
|
||||
if g.httpClient.Timeout != 15*time.Second {
|
||||
t.Errorf("httpClient timeout = %v, want 15s", g.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// --- VerifyToken ---
|
||||
|
||||
func TestVerifyToken_Success(t *testing.T) {
|
||||
var gotPath, gotAuth string
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
_, _ = w.Write([]byte(`{"id":"111","name":"TestPage"}`))
|
||||
}))
|
||||
if err := g.VerifyToken(context.Background()); err != nil {
|
||||
t.Fatalf("VerifyToken: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotPath, "/me") {
|
||||
t.Errorf("path = %q, want contains /me", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer fake-token" {
|
||||
t.Errorf("auth = %q, want Bearer fake-token", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyToken_HTTPError(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":190,"message":"token expired"}}`))
|
||||
}))
|
||||
err := g.VerifyToken(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !IsAuthError(err) {
|
||||
t.Errorf("expected IsAuthError(err) true, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyToken_ParseError(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`not-json`))
|
||||
}))
|
||||
if err := g.VerifyToken(context.Background()); err == nil {
|
||||
t.Fatal("expected parse error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- SubscribeApp ---
|
||||
|
||||
func TestSubscribeApp_Success(t *testing.T) {
|
||||
var gotPath, gotMethod string
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
if err := g.SubscribeApp(context.Background()); err != nil {
|
||||
t.Fatalf("SubscribeApp: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotPath, "/subscribed_apps") {
|
||||
t.Errorf("path = %q, want subscribed_apps", gotPath)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeApp_InvalidPageID(t *testing.T) {
|
||||
// Don't need a server — validateFBID will reject before doRequest runs.
|
||||
g := &GraphClient{
|
||||
httpClient: &http.Client{},
|
||||
pageAccessToken: "tok",
|
||||
pageID: "bad id", // space not allowed
|
||||
}
|
||||
err := g.SubscribeApp(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid pageID, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid facebook ID") {
|
||||
t.Errorf("err = %v, want ID validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetPost / GetComment / GetCommentThread ---
|
||||
|
||||
func TestGetPost_Success(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"id":"777","message":"hello","created_time":"2026-04-11T00:00:00Z"}`))
|
||||
}))
|
||||
post, err := g.GetPost(context.Background(), "777")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPost: %v", err)
|
||||
}
|
||||
if post.ID != "777" || post.Message != "hello" {
|
||||
t.Errorf("post = %+v", post)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPost_RejectsBadID(t *testing.T) {
|
||||
g := NewGraphClient("tok", "111")
|
||||
if _, err := g.GetPost(context.Background(), ""); err == nil {
|
||||
t.Fatal("expected error for empty id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPost_ParseError(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"id":777}`)) // wrong type — ID should be string
|
||||
}))
|
||||
// This actually unmarshals fine (ID: "" because of type mismatch skipped), let's
|
||||
// force a real parse error with outright invalid JSON.
|
||||
_ = g
|
||||
g2 := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`not-json`))
|
||||
}))
|
||||
if _, err := g2.GetPost(context.Background(), "777"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetComment_Success(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"id":"111222","message":"hi","from":{"id":"u1","name":"Alice"}}`))
|
||||
}))
|
||||
c, err := g.GetComment(context.Background(), "111222")
|
||||
if err != nil {
|
||||
t.Fatalf("GetComment: %v", err)
|
||||
}
|
||||
if c.ID != "111222" || c.From.Name != "Alice" {
|
||||
t.Errorf("comment = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetComment_InvalidID(t *testing.T) {
|
||||
g := NewGraphClient("tok", "111")
|
||||
if _, err := g.GetComment(context.Background(), "invalid id"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetComment_ParseError(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`not json`))
|
||||
}))
|
||||
if _, err := g.GetComment(context.Background(), "123"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommentThread_Success(t *testing.T) {
|
||||
var gotQuery string
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"111222","message":"a","from":{"id":"u1"}},{"id":"c2","message":"b","from":{"id":"u2"}}]}`))
|
||||
}))
|
||||
got, err := g.GetCommentThread(context.Background(), "999888", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommentThread: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if !strings.Contains(gotQuery, "limit=5") {
|
||||
t.Errorf("query = %q, want limit=5", gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommentThread_ZeroLimitDefaults(t *testing.T) {
|
||||
var gotQuery string
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}))
|
||||
if _, err := g.GetCommentThread(context.Background(), "999888", 0); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "limit=10") {
|
||||
t.Errorf("query = %q, want default limit=10", gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommentThread_InvalidParent(t *testing.T) {
|
||||
g := NewGraphClient("tok", "111")
|
||||
if _, err := g.GetCommentThread(context.Background(), "bad id", 5); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommentThread_ParseError(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`bad`))
|
||||
}))
|
||||
if _, err := g.GetCommentThread(context.Background(), "123", 5); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ReplyToComment / SendMessage / SendTypingOn ---
|
||||
|
||||
func TestReplyToComment_Success(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
_, _ = w.Write([]byte(`{"id":"new-reply-1"}`))
|
||||
}))
|
||||
id, err := g.ReplyToComment(context.Background(), "111222", "thanks!")
|
||||
if err != nil {
|
||||
t.Fatalf("ReplyToComment: %v", err)
|
||||
}
|
||||
if id != "new-reply-1" {
|
||||
t.Errorf("id = %q, want new-reply-1", id)
|
||||
}
|
||||
if gotBody["message"] != "thanks!" {
|
||||
t.Errorf("body message = %v", gotBody["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyToComment_InvalidID(t *testing.T) {
|
||||
g := NewGraphClient("tok", "111")
|
||||
if _, err := g.ReplyToComment(context.Background(), "", "msg"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyToComment_ParseError(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`broken`))
|
||||
}))
|
||||
if _, err := g.ReplyToComment(context.Background(), "123", "msg"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_SuccessAndBodyShape(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
_, _ = w.Write([]byte(`{"message_id":"mid-42"}`))
|
||||
}))
|
||||
id, err := g.SendMessage(context.Background(), "user-1", "hi")
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
if id != "mid-42" {
|
||||
t.Errorf("id = %q, want mid-42", id)
|
||||
}
|
||||
rec, _ := gotBody["recipient"].(map[string]any)
|
||||
if rec == nil || rec["id"] != "user-1" {
|
||||
t.Errorf("recipient = %v", gotBody["recipient"])
|
||||
}
|
||||
msg, _ := gotBody["message"].(map[string]any)
|
||||
if msg == nil || msg["text"] != "hi" {
|
||||
t.Errorf("message = %v", gotBody["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_ParseError(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`fail`))
|
||||
}))
|
||||
if _, err := g.SendMessage(context.Background(), "u", "m"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTypingOn_Success(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
if err := g.SendTypingOn(context.Background(), "user-9"); err != nil {
|
||||
t.Fatalf("SendTypingOn: %v", err)
|
||||
}
|
||||
if gotBody["sender_action"] != "typing_on" {
|
||||
t.Errorf("sender_action = %v", gotBody["sender_action"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- doRequest retry + error paths ---
|
||||
|
||||
func TestDoRequest_500RetriesThenSucceeds(t *testing.T) {
|
||||
var attempts int32
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&attempts, 1)
|
||||
if n < 3 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":"ok","name":"n"}`))
|
||||
}))
|
||||
// Use a long context so backoff sleeps don't exceed the deadline.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := g.VerifyToken(ctx); err != nil {
|
||||
t.Fatalf("VerifyToken after retries: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got != 3 {
|
||||
t.Errorf("attempts = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_AllowsGraphErrorPropagation(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":100,"message":"param bad"}}`))
|
||||
}))
|
||||
_, err := g.GetPost(context.Background(), "111")
|
||||
if err == nil {
|
||||
t.Fatal("expected graph api error")
|
||||
}
|
||||
var ge *graphAPIError
|
||||
if !errors.As(err, &ge) {
|
||||
t.Fatalf("err = %v, want *graphAPIError", err)
|
||||
}
|
||||
if ge.code != 100 {
|
||||
t.Errorf("code = %d, want 100", ge.code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_24hWindowErrorNotRetried(t *testing.T) {
|
||||
var attempts int32
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":551,"message":"24h window"}}`))
|
||||
}))
|
||||
_, err := g.SendMessage(context.Background(), "u", "m")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got != 1 {
|
||||
t.Errorf("attempts = %d, want 1 (no retry on 551)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_SubcodeTriggers24hNotRetried(t *testing.T) {
|
||||
var attempts int32
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
// Different top-level code but matching subcode.
|
||||
_, _ = w.Write([]byte(`{"error":{"code":200,"error_subcode":2018109,"message":"outside window"}}`))
|
||||
}))
|
||||
_, err := g.SendMessage(context.Background(), "u", "m")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got != 1 {
|
||||
t.Errorf("attempts = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_429RetriesWithBackoff(t *testing.T) {
|
||||
var attempts int32
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&attempts, 1)
|
||||
if n == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":4,"message":"rate limited"}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":"ok","name":"n"}`))
|
||||
}))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := g.VerifyToken(ctx); err != nil {
|
||||
t.Fatalf("VerifyToken after 429 retry: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got < 2 {
|
||||
t.Errorf("attempts = %d, want ≥2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_ContextCancelledDuringBackoff(t *testing.T) {
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", "60") // force a long sleep
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":4,"message":"rate limited"}}`))
|
||||
}))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Cancel shortly after dispatch.
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
_, err := g.GetPost(ctx, "111")
|
||||
if err == nil {
|
||||
t.Fatal("expected context cancel error")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_TransportErrorThenSuccess(t *testing.T) {
|
||||
// Use a client that fails the first request with a transport error.
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
if n == 1 {
|
||||
// Hijack and close to trigger a transport error on the client.
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
t.Fatal("ResponseWriter not a Hijacker")
|
||||
}
|
||||
conn, _, _ := hj.Hijack()
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":"ok","name":"n"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
g := NewGraphClient("tok", "111")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := g.VerifyToken(ctx); err != nil {
|
||||
t.Fatalf("VerifyToken: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got < 2 {
|
||||
t.Errorf("calls = %d, want ≥2 (retry after transport error)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_401RetriesExhaustedReturnsErr(t *testing.T) {
|
||||
var calls int32
|
||||
g := newFakeGraph(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":190,"message":"token expired"}}`))
|
||||
}))
|
||||
_, err := g.GetPost(context.Background(), "111")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !IsAuthError(err) {
|
||||
t.Errorf("err = %v, want auth error", err)
|
||||
}
|
||||
// Auth errors are non-retryable at the Graph API level (not 5xx, not 429).
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("calls = %d, want 1 (no retry on 401 with code 190)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- logRateLimit ---
|
||||
|
||||
func TestLogRateLimit_HighUsageDoesNotPanic(t *testing.T) {
|
||||
g := NewGraphClient("tok", "111")
|
||||
mk := func(header string) *http.Response {
|
||||
h := http.Header{}
|
||||
h.Set("X-Business-Use-Case-Usage", header)
|
||||
return &http.Response{Header: h}
|
||||
}
|
||||
// Each call should complete without panic (log warn only).
|
||||
g.logRateLimit(mk(""))
|
||||
g.logRateLimit(mk(`not-json`))
|
||||
g.logRateLimit(mk(fmt.Sprintf(`{"111":[{"call_count":%d}]}`, 50)))
|
||||
g.logRateLimit(mk(fmt.Sprintf(`{"111":[{"call_count":%d}]}`, 85)))
|
||||
g.logRateLimit(mk(fmt.Sprintf(`{"111":[{"call_count":%d}]}`, 97)))
|
||||
}
|
||||
|
||||
// TestErrorClassifiersExtraCodes covers the additional rate-limit codes
|
||||
// (17, 32, 613) and the 102 auth alias that aren't in facebook_test.go.
|
||||
func TestErrorClassifiersExtraCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
auth bool
|
||||
perm bool
|
||||
rate bool
|
||||
}{
|
||||
{102, true, false, false},
|
||||
{200, false, true, false},
|
||||
{17, false, false, true},
|
||||
{32, false, false, true},
|
||||
{613, false, false, true},
|
||||
{1, false, false, false}, // unknown code matches nothing
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := &graphAPIError{code: tc.code}
|
||||
if got := IsAuthError(err); got != tc.auth {
|
||||
t.Errorf("code %d IsAuth = %v, want %v", tc.code, got, tc.auth)
|
||||
}
|
||||
if got := IsPermissionError(err); got != tc.perm {
|
||||
t.Errorf("code %d IsPerm = %v, want %v", tc.code, got, tc.perm)
|
||||
}
|
||||
if got := IsRateLimitError(err); got != tc.rate {
|
||||
t.Errorf("code %d IsRate = %v, want %v", tc.code, got, tc.rate)
|
||||
}
|
||||
}
|
||||
// non-graph error → all false
|
||||
other := errors.New("plain")
|
||||
if IsAuthError(other) || IsPermissionError(other) || IsRateLimitError(other) {
|
||||
t.Error("plain error should not match any classifier")
|
||||
}
|
||||
}
|
||||
|
||||
// --- graphAPIError Error() ---
|
||||
|
||||
func TestGraphAPIErrorMessage(t *testing.T) {
|
||||
e := &graphAPIError{code: 17, msg: "user limit"}
|
||||
s := e.Error()
|
||||
if !strings.Contains(s, "17") || !strings.Contains(s, "user limit") {
|
||||
t.Errorf("Error() = %q, want both code and msg", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
package facebook
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
)
|
||||
|
||||
// newTestChannel wires a facebook Channel to a mock Graph API server with
|
||||
// reasonable defaults for handler/post-fetcher tests. Returns the channel +
|
||||
// the server so callers can close it via t.Cleanup.
|
||||
func newTestChannel(t *testing.T, pageID string, cfg facebookInstanceConfig) *Channel {
|
||||
t.Helper()
|
||||
|
||||
// Minimal Graph mock that responds OK to post/comment/message endpoints.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"id":"999","name":"Mock","message":"post text","data":[]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
|
||||
mb := bus.New()
|
||||
cfg.PageID = pageID
|
||||
ch, err := New(cfg, facebookCreds{
|
||||
PageAccessToken: "tok",
|
||||
AppSecret: "sec",
|
||||
VerifyToken: "vt",
|
||||
}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// TestNew_RejectsMissingCredentials covers the four required-field branches
|
||||
// in New(): page_access_token, page_id, app_secret, verify_token.
|
||||
func TestNew_RejectsMissingCredentials(t *testing.T) {
|
||||
mb := bus.New()
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg facebookInstanceConfig
|
||||
creds facebookCreds
|
||||
}{
|
||||
{"missing page_access_token", facebookInstanceConfig{PageID: "111"}, facebookCreds{AppSecret: "s", VerifyToken: "v"}},
|
||||
{"missing page_id", facebookInstanceConfig{}, facebookCreds{PageAccessToken: "t", AppSecret: "s", VerifyToken: "v"}},
|
||||
{"missing app_secret", facebookInstanceConfig{PageID: "111"}, facebookCreds{PageAccessToken: "t", VerifyToken: "v"}},
|
||||
{"missing verify_token", facebookInstanceConfig{PageID: "111"}, facebookCreds{PageAccessToken: "t", AppSecret: "s"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := New(tc.cfg, tc.creds, mb, nil); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_ValidInputsProducesChannel verifies Factory deserializes
|
||||
// creds+config JSON and hands off to New.
|
||||
func TestFactory_ValidInputsProducesChannel(t *testing.T) {
|
||||
creds := []byte(`{"page_access_token":"tok","app_secret":"sec","verify_token":"vt"}`)
|
||||
cfg := []byte(`{"page_id":"111","features":{"comment_reply":true}}`)
|
||||
ch, err := Factory("fb-test", creds, cfg, bus.New(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Factory: %v", err)
|
||||
}
|
||||
if ch.Name() != "fb-test" {
|
||||
t.Errorf("Name() = %q, want fb-test", ch.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_MalformedCreds and TestFactory_MalformedConfig verify the
|
||||
// error-branch paths.
|
||||
func TestFactory_MalformedCreds(t *testing.T) {
|
||||
if _, err := Factory("x", []byte(`{bad`), []byte(`{}`), bus.New(), nil); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactory_MalformedConfig(t *testing.T) {
|
||||
creds := []byte(`{"page_access_token":"t","app_secret":"s","verify_token":"v"}`)
|
||||
if _, err := Factory("x", creds, []byte(`{bad`), bus.New(), nil); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_MissingRequiredSurfacesNewError verifies Factory propagates the
|
||||
// New() validation errors (e.g. missing page_id).
|
||||
func TestFactory_MissingRequiredSurfacesNewError(t *testing.T) {
|
||||
creds := []byte(`{"page_access_token":"t","app_secret":"s","verify_token":"v"}`)
|
||||
if _, err := Factory("x", creds, []byte(`{}`), bus.New(), nil); err == nil {
|
||||
t.Fatal("expected error for missing page_id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookHandler_ReturnsRouteFirstCallOnly verifies the singleton webhook
|
||||
// route is registered on the first instance and nil thereafter.
|
||||
func TestWebhookHandler_ReturnsRouteFirstCallOnly(t *testing.T) {
|
||||
// Reset router to a known state.
|
||||
globalRouter = &webhookRouter{instances: make(map[string]*Channel)}
|
||||
|
||||
ch := newTestChannel(t, "111", facebookInstanceConfig{})
|
||||
path1, h1 := ch.WebhookHandler()
|
||||
if path1 == "" || h1 == nil {
|
||||
t.Fatal("first call should return path+handler")
|
||||
}
|
||||
path2, h2 := ch.WebhookHandler()
|
||||
if path2 != "" || h2 != nil {
|
||||
t.Errorf("subsequent call should return empty, got %q / %v", path2, h2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleAPIError_MapsToHealthStates verifies handleAPIError dispatches
|
||||
// based on error classification. Uses MarkFailed/MarkDegraded observable
|
||||
// channel health state as the post-condition.
|
||||
func TestHandleAPIError_MapsToHealthStates(t *testing.T) {
|
||||
mb := bus.New()
|
||||
ch, err := New(facebookInstanceConfig{PageID: "111"}, facebookCreds{
|
||||
PageAccessToken: "t", AppSecret: "s", VerifyToken: "v",
|
||||
}, mb, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// nil — no-op
|
||||
ch.handleAPIError(nil)
|
||||
|
||||
// auth error → Failed
|
||||
ch.handleAPIError(&graphAPIError{code: 190, msg: "tok expired"})
|
||||
|
||||
// permission error → Failed
|
||||
ch.handleAPIError(&graphAPIError{code: 10, msg: "permission denied"})
|
||||
|
||||
// rate limit → Degraded
|
||||
ch.handleAPIError(&graphAPIError{code: 4, msg: "rate limited"})
|
||||
|
||||
// unknown → Degraded
|
||||
ch.handleAPIError(&graphAPIError{code: 999, msg: "?"})
|
||||
}
|
||||
|
||||
// TestHandleCommentEvent_FeatureGated verifies comment events are silently
|
||||
// ignored when the comment_reply feature is off.
|
||||
func TestHandleCommentEvent_FeatureGated(t *testing.T) {
|
||||
ch := newTestChannel(t, "111", facebookInstanceConfig{})
|
||||
// Feature not enabled → handler returns early without side effects.
|
||||
ch.handleCommentEvent(WebhookEntry{ID: "111"}, ChangeValue{Item: "comment", Verb: "add", CommentID: "c1"})
|
||||
}
|
||||
|
||||
// TestHandleCommentEvent_DropsEditsAndRemoves verifies only Verb="add" events
|
||||
// proceed through the handler.
|
||||
func TestHandleCommentEvent_DropsEditsAndRemoves(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.CommentReply = true
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
|
||||
ch.handleCommentEvent(WebhookEntry{ID: "111"}, ChangeValue{Verb: "edit", CommentID: "c1"})
|
||||
ch.handleCommentEvent(WebhookEntry{ID: "111"}, ChangeValue{Verb: "remove", CommentID: "c2"})
|
||||
}
|
||||
|
||||
// TestHandleCommentEvent_PageRouting verifies events for other pages are
|
||||
// dropped (entry.ID != pageID).
|
||||
func TestHandleCommentEvent_PageRouting(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.CommentReply = true
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
// Different page in entry.ID → dropped.
|
||||
ch.handleCommentEvent(WebhookEntry{ID: "999"}, ChangeValue{Verb: "add", CommentID: "c1", From: FBUser{ID: "u1"}})
|
||||
}
|
||||
|
||||
// TestHandleCommentEvent_SelfReplySkipped verifies comments from the page
|
||||
// itself are ignored (prevents reply loops).
|
||||
func TestHandleCommentEvent_SelfReplySkipped(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.CommentReply = true
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
ch.handleCommentEvent(WebhookEntry{ID: "111"}, ChangeValue{
|
||||
Verb: "add",
|
||||
CommentID: "c1",
|
||||
From: FBUser{ID: "111"}, // matches pageID → self reply
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleCommentEvent_DedupSecondCallDropped verifies the same event
|
||||
// delivered twice only dispatches once. Observation is indirect; we just
|
||||
// verify no panic and two calls complete.
|
||||
func TestHandleCommentEvent_DedupSecondCallDropped(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.CommentReply = true
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
|
||||
change := ChangeValue{
|
||||
Verb: "add",
|
||||
CommentID: "c-dedup",
|
||||
From: FBUser{ID: "u1", Name: "Alice"},
|
||||
PostID: "p1",
|
||||
Message: "hi",
|
||||
}
|
||||
ch.handleCommentEvent(WebhookEntry{ID: "111"}, change)
|
||||
ch.handleCommentEvent(WebhookEntry{ID: "111"}, change) // second call hits dedup
|
||||
}
|
||||
|
||||
// TestHandleCommentEvent_EnrichedContent verifies the enrichment path runs
|
||||
// without error when IncludePostContext is enabled.
|
||||
func TestHandleCommentEvent_EnrichedContent(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.CommentReply = true
|
||||
cfg.CommentReplyOptions.IncludePostContext = true
|
||||
cfg.CommentReplyOptions.MaxThreadDepth = 3
|
||||
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
ch.handleCommentEvent(WebhookEntry{ID: "111"}, ChangeValue{
|
||||
Verb: "add",
|
||||
CommentID: "c-enrich",
|
||||
From: FBUser{ID: "u1", Name: "Alice"},
|
||||
PostID: "111_222",
|
||||
ParentID: "111_333",
|
||||
Message: "question about post",
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleMessagingEvent_FeatureGated verifies Messenger events are
|
||||
// dropped when messenger_auto_reply is off.
|
||||
func TestHandleMessagingEvent_FeatureGated(t *testing.T) {
|
||||
ch := newTestChannel(t, "111", facebookInstanceConfig{})
|
||||
ch.handleMessagingEvent(WebhookEntry{ID: "111"}, MessagingEvent{
|
||||
Sender: FBUser{ID: "u1"},
|
||||
Message: &IncomingMessage{MID: "m1", Text: "hi"},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleMessagingEvent_PageRouting verifies events for other pages are dropped.
|
||||
func TestHandleMessagingEvent_PageRouting(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.MessengerAutoReply = true
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
ch.handleMessagingEvent(WebhookEntry{ID: "999"}, MessagingEvent{
|
||||
Sender: FBUser{ID: "u1"},
|
||||
Message: &IncomingMessage{MID: "m1", Text: "hi"},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleMessagingEvent_SelfSkipped verifies messages sent by the page
|
||||
// itself are dropped.
|
||||
func TestHandleMessagingEvent_SelfSkipped(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.MessengerAutoReply = true
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
ch.handleMessagingEvent(WebhookEntry{ID: "111"}, MessagingEvent{
|
||||
Sender: FBUser{ID: "111"}, // self
|
||||
Message: &IncomingMessage{MID: "m1", Text: "hi"},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleMessagingEvent_ReceiptsDropped verifies delivery/read receipts
|
||||
// (nil Message and nil Postback) are silently dropped.
|
||||
func TestHandleMessagingEvent_ReceiptsDropped(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.MessengerAutoReply = true
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
ch.handleMessagingEvent(WebhookEntry{ID: "111"}, MessagingEvent{
|
||||
Sender: FBUser{ID: "u1"},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleMessagingEvent_TextAndPostback verifies both content branches.
|
||||
func TestHandleMessagingEvent_TextAndPostback(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.MessengerAutoReply = true
|
||||
cfg.MessengerOptions.SessionTimeout = "15m"
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
|
||||
// Text message
|
||||
ch.handleMessagingEvent(WebhookEntry{ID: "111"}, MessagingEvent{
|
||||
Sender: FBUser{ID: "u1"},
|
||||
Timestamp: 1111,
|
||||
Message: &IncomingMessage{MID: "m1", Text: "hi"},
|
||||
})
|
||||
|
||||
// Postback
|
||||
ch.handleMessagingEvent(WebhookEntry{ID: "111"}, MessagingEvent{
|
||||
Sender: FBUser{ID: "u2"},
|
||||
Timestamp: 2222,
|
||||
Postback: &Postback{Title: "Start", Payload: "START"},
|
||||
})
|
||||
|
||||
// Attachment-only (text empty, nil postback → dropped)
|
||||
ch.handleMessagingEvent(WebhookEntry{ID: "111"}, MessagingEvent{
|
||||
Sender: FBUser{ID: "u3"},
|
||||
Timestamp: 3333,
|
||||
Message: &IncomingMessage{MID: "m3", Text: ""},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleMessagingEvent_DedupSkipped verifies duplicate MIDs are deduped.
|
||||
func TestHandleMessagingEvent_DedupSkipped(t *testing.T) {
|
||||
cfg := facebookInstanceConfig{}
|
||||
cfg.Features.MessengerAutoReply = true
|
||||
ch := newTestChannel(t, "111", cfg)
|
||||
|
||||
event := MessagingEvent{
|
||||
Sender: FBUser{ID: "u1"},
|
||||
Message: &IncomingMessage{MID: "dedup-mid", Text: "hi"},
|
||||
}
|
||||
ch.handleMessagingEvent(WebhookEntry{ID: "111"}, event)
|
||||
ch.handleMessagingEvent(WebhookEntry{ID: "111"}, event) // dedup drops
|
||||
}
|
||||
|
||||
// TestSend_MessengerMode verifies Send routes to the Messenger API when
|
||||
// fb_mode metadata is "messenger".
|
||||
func TestSend_MessengerMode(t *testing.T) {
|
||||
var sends int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sends++
|
||||
_, _ = w.Write([]byte(`{"message_id":"mid"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
|
||||
mb := bus.New()
|
||||
ch, _ := New(facebookInstanceConfig{PageID: "111"}, facebookCreds{
|
||||
PageAccessToken: "t", AppSecret: "s", VerifyToken: "v",
|
||||
}, mb, nil)
|
||||
|
||||
err := ch.Send(t.Context(), bus.OutboundMessage{
|
||||
ChatID: "user-1",
|
||||
Content: "hello",
|
||||
Metadata: map[string]string{"fb_mode": "messenger"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if sends == 0 {
|
||||
t.Error("expected at least one send")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_CommentModeMissingMetadata verifies the comment path errors when
|
||||
// reply_to_comment_id metadata is absent.
|
||||
func TestSend_CommentModeMissingMetadata(t *testing.T) {
|
||||
ch := newTestChannel(t, "111", facebookInstanceConfig{})
|
||||
err := ch.Send(t.Context(), bus.OutboundMessage{
|
||||
ChatID: "111_222",
|
||||
Content: "reply",
|
||||
Metadata: map[string]string{"fb_mode": "comment"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing reply_to_comment_id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_CommentModeWithMetadata verifies the comment path reaches
|
||||
// ReplyToComment when metadata is provided.
|
||||
func TestSend_CommentModeWithMetadata(t *testing.T) {
|
||||
var replies int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
replies++
|
||||
_, _ = w.Write([]byte(`{"id":"new-reply"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
|
||||
mb := bus.New()
|
||||
ch, _ := New(facebookInstanceConfig{PageID: "111"}, facebookCreds{
|
||||
PageAccessToken: "t", AppSecret: "s", VerifyToken: "v",
|
||||
}, mb, nil)
|
||||
err := ch.Send(t.Context(), bus.OutboundMessage{
|
||||
Content: "thanks",
|
||||
Metadata: map[string]string{
|
||||
"fb_mode": "comment",
|
||||
"reply_to_comment_id": "555666",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if replies != 1 {
|
||||
t.Errorf("replies = %d, want 1", replies)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendFirstInbox_OnceThenSkipped verifies the firstInboxSent dedup:
|
||||
// first call sends, second (same sender) is a no-op.
|
||||
func TestSendFirstInbox_OnceThenSkipped(t *testing.T) {
|
||||
var sends int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sends++
|
||||
_, _ = w.Write([]byte(`{"message_id":"mid"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
|
||||
mb := bus.New()
|
||||
ch, _ := New(facebookInstanceConfig{PageID: "111", FirstInboxMessage: "hi"}, facebookCreds{
|
||||
PageAccessToken: "t", AppSecret: "s", VerifyToken: "v",
|
||||
}, mb, nil)
|
||||
|
||||
ch.sendFirstInbox(t.Context(), "user-1")
|
||||
ch.sendFirstInbox(t.Context(), "user-1") // second call skipped
|
||||
|
||||
if sends != 1 {
|
||||
t.Errorf("sends = %d, want 1 (second call deduped)", sends)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDedupCleaner_EvictsStale verifies runDedupCleaner removes entries
|
||||
// older than dedupTTL. We exercise the loop body directly via stop signal.
|
||||
func TestRunDedupCleaner_EvictsStale(t *testing.T) {
|
||||
ch := &Channel{stopCh: make(chan struct{})}
|
||||
// Pre-populate with a stale and a fresh entry.
|
||||
ch.dedup.Store("stale", time.Now().Add(-dedupTTL-time.Hour))
|
||||
ch.dedup.Store("fresh", time.Now())
|
||||
|
||||
go ch.runDedupCleaner()
|
||||
// Give the ticker enough time to not fire — cleanup is event-driven.
|
||||
// Instead, exercise the eviction logic directly since the goroutine only
|
||||
// runs on each 5-minute tick.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
close(ch.stopCh) // stop the goroutine
|
||||
|
||||
// Run the eviction logic synchronously (mirrors the for-loop body).
|
||||
now := time.Now()
|
||||
ch.dedup.Range(func(k, v any) bool {
|
||||
if t2, ok := v.(time.Time); ok && now.Sub(t2) > dedupTTL {
|
||||
ch.dedup.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if _, present := ch.dedup.Load("stale"); present {
|
||||
t.Error("stale entry should have been evicted")
|
||||
}
|
||||
if _, present := ch.dedup.Load("fresh"); !present {
|
||||
t.Error("fresh entry should still be present")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStop_ClosesDependencies verifies Stop toggles running, closes stopCh,
|
||||
// and cancels the stop context so inflight Graph requests abort.
|
||||
func TestStop_ClosesDependencies(t *testing.T) {
|
||||
// Reset router to isolate this test.
|
||||
globalRouter = &webhookRouter{instances: make(map[string]*Channel)}
|
||||
|
||||
ch := newTestChannel(t, "111", facebookInstanceConfig{})
|
||||
ch.SetRunning(true)
|
||||
// Register so Stop's unregister path exercises.
|
||||
globalRouter.register(ch)
|
||||
|
||||
if err := ch.Stop(t.Context()); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("running still true after Stop")
|
||||
}
|
||||
select {
|
||||
case <-ch.stopCtx.Done():
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("stopCtx not cancelled after Stop")
|
||||
}
|
||||
select {
|
||||
case <-ch.stopCh:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("stopCh not closed after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
// --- webhook_router ---
|
||||
|
||||
// TestWebhookRouter_RegisterUnregister verifies basic register/unregister
|
||||
// operations mutate the instances map.
|
||||
func TestWebhookRouter_RegisterUnregister(t *testing.T) {
|
||||
r := &webhookRouter{instances: make(map[string]*Channel)}
|
||||
ch := &Channel{pageID: "111"}
|
||||
r.register(ch)
|
||||
r.mu.RLock()
|
||||
_, present := r.instances["111"]
|
||||
r.mu.RUnlock()
|
||||
if !present {
|
||||
t.Error("register did not insert")
|
||||
}
|
||||
r.unregister("111")
|
||||
r.mu.RLock()
|
||||
_, present = r.instances["111"]
|
||||
r.mu.RUnlock()
|
||||
if present {
|
||||
t.Error("unregister did not remove")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookRouter_RouteOnceReturnsEmptyAfter verifies webhookRoute is
|
||||
// single-shot per router.
|
||||
func TestWebhookRouter_RouteOnceReturnsEmptyAfter(t *testing.T) {
|
||||
r := &webhookRouter{instances: make(map[string]*Channel)}
|
||||
path1, h1 := r.webhookRoute()
|
||||
if path1 == "" || h1 == nil {
|
||||
t.Fatal("first call returned empty")
|
||||
}
|
||||
path2, h2 := r.webhookRoute()
|
||||
if path2 != "" || h2 != nil {
|
||||
t.Errorf("second call should be empty, got %q / %v", path2, h2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookRouter_ServeHTTPNoInstances verifies ServeHTTP returns 200
|
||||
// when no instances are registered (no crash, graceful degrade).
|
||||
func TestWebhookRouter_ServeHTTPNoInstances(t *testing.T) {
|
||||
r := &webhookRouter{instances: make(map[string]*Channel)}
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PostFetcher ---
|
||||
|
||||
// TestNewPostFetcher_Defaults verifies empty TTL string defaults to
|
||||
// defaultPostCacheTTL; invalid string also falls back; valid parses through.
|
||||
func TestNewPostFetcher_Defaults(t *testing.T) {
|
||||
g := NewGraphClient("tok", "111")
|
||||
|
||||
pf := NewPostFetcher(g, "")
|
||||
if pf.cacheTTL != defaultPostCacheTTL {
|
||||
t.Errorf("empty TTL: got %v, want %v", pf.cacheTTL, defaultPostCacheTTL)
|
||||
}
|
||||
pf2 := NewPostFetcher(g, "bogus")
|
||||
if pf2.cacheTTL != defaultPostCacheTTL {
|
||||
t.Errorf("invalid TTL: got %v, want %v", pf2.cacheTTL, defaultPostCacheTTL)
|
||||
}
|
||||
pf3 := NewPostFetcher(g, "30m")
|
||||
if pf3.cacheTTL != 30*time.Minute {
|
||||
t.Errorf("30m TTL: got %v", pf3.cacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostFetcher_GetPost_EmptyIDReturnsNil verifies the empty-ID short-circuit.
|
||||
func TestPostFetcher_GetPost_EmptyIDReturnsNil(t *testing.T) {
|
||||
pf := NewPostFetcher(NewGraphClient("t", "111"), "")
|
||||
post, err := pf.GetPost(t.Context(), "")
|
||||
if err != nil || post != nil {
|
||||
t.Errorf("empty id: got post=%v err=%v, want both nil", post, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostFetcher_GetPost_CacheHit verifies a pre-populated cache entry
|
||||
// returns without hitting the Graph API.
|
||||
func TestPostFetcher_GetPost_CacheHit(t *testing.T) {
|
||||
// Server that records calls — cache hit should leave this at zero.
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
_, _ = w.Write([]byte(`{"id":"111_222","message":"mock"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
|
||||
pf := NewPostFetcher(NewGraphClient("t", "111"), "15m")
|
||||
// Pre-populate cache.
|
||||
pf.cache.Store("111_222", &postCacheEntry{
|
||||
post: &GraphPost{ID: "111_222", Message: "cached"},
|
||||
expiresAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
got, err := pf.GetPost(t.Context(), "111_222")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPost: %v", err)
|
||||
}
|
||||
if got.Message != "cached" {
|
||||
t.Errorf("got %q, want cached", got.Message)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Errorf("calls = %d, want 0 (cache hit)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostFetcher_GetPost_CacheMissFetches verifies a missing cache entry
|
||||
// triggers an upstream Graph API call and stores the result.
|
||||
func TestPostFetcher_GetPost_CacheMissFetches(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
_, _ = w.Write([]byte(`{"id":"111_222","message":"fresh","created_time":"now"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
|
||||
pf := NewPostFetcher(NewGraphClient("t", "111"), "15m")
|
||||
got, err := pf.GetPost(t.Context(), "111_222")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPost: %v", err)
|
||||
}
|
||||
if got.Message != "fresh" {
|
||||
t.Errorf("got %q, want fresh", got.Message)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("calls = %d, want 1", calls)
|
||||
}
|
||||
// Second call should hit the cache now.
|
||||
_, _ = pf.GetPost(t.Context(), "111_222")
|
||||
if calls != 1 {
|
||||
t.Errorf("calls after 2nd get = %d, want 1 (cache hit)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostFetcher_GetPost_ExpiredRefetches verifies an expired entry is
|
||||
// evicted and the fresh value is fetched and stored.
|
||||
func TestPostFetcher_GetPost_ExpiredRefetches(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
_, _ = w.Write([]byte(`{"id":"111_222","message":"new","created_time":"now"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
|
||||
pf := NewPostFetcher(NewGraphClient("t", "111"), "15m")
|
||||
pf.cache.Store("111_222", &postCacheEntry{
|
||||
post: &GraphPost{Message: "stale"},
|
||||
expiresAt: time.Now().Add(-time.Minute), // already expired
|
||||
})
|
||||
got, err := pf.GetPost(t.Context(), "111_222")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPost: %v", err)
|
||||
}
|
||||
if got.Message != "new" {
|
||||
t.Errorf("got %q, want new (refetched after expiry)", got.Message)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostFetcher_GetPost_PropagatesError verifies an upstream Graph API
|
||||
// error surfaces through the singleflight call.
|
||||
func TestPostFetcher_GetPost_PropagatesError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":100,"message":"bad"}}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
|
||||
pf := NewPostFetcher(NewGraphClient("t", "111"), "15m")
|
||||
_, err := pf.GetPost(t.Context(), "111_222")
|
||||
if err == nil {
|
||||
t.Fatal("expected error propagated through singleflight")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostFetcher_GetCommentThread_EmptyParent verifies nil/nil short-circuit.
|
||||
func TestPostFetcher_GetCommentThread_EmptyParent(t *testing.T) {
|
||||
pf := NewPostFetcher(NewGraphClient("t", "111"), "15m")
|
||||
got, err := pf.GetCommentThread(t.Context(), "", 5)
|
||||
if err != nil || got != nil {
|
||||
t.Errorf("empty parent: got %v / %v, want nil/nil", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostFetcher_GetCommentThread_Delegates verifies non-empty parent hits
|
||||
// the underlying GraphClient.GetCommentThread.
|
||||
func TestPostFetcher_GetCommentThread_Delegates(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"c1","message":"a","from":{"id":"u1"}}]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
|
||||
pf := NewPostFetcher(NewGraphClient("t", "111"), "15m")
|
||||
got, err := pf.GetCommentThread(t.Context(), "111_222", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("len = %d, want 1", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- config_permissions.go: evalPermRows + matchWildcard + itoa ---
|
||||
|
||||
func TestMatchWildcard(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{"*", "anything", true},
|
||||
{"*", "", true},
|
||||
{"group:*", "group:telegram:123", true},
|
||||
{"group:*", "group:telegram", true},
|
||||
{"group:*", "dm:telegram", false},
|
||||
{"group:telegram:*", "group:telegram:123", true},
|
||||
{"group:telegram:*", "group:telegram", true},
|
||||
{"group:telegram:*", "group:slack:123", false},
|
||||
{"exact", "exact", true},
|
||||
{"exact", "other", false},
|
||||
{"group:telegram:-100456", "group:telegram:-100456", true},
|
||||
{"group:telegram:-100456", "group:telegram:-100457", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.pattern+" vs "+tc.value, func(t *testing.T) {
|
||||
if got := matchWildcard(tc.pattern, tc.value); got != tc.want {
|
||||
t.Errorf("matchWildcard(%q, %q) = %v, want %v", tc.pattern, tc.value, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalPermRows_IndividualDenyWinsOverGroupAllow(t *testing.T) {
|
||||
rows := []permRow{
|
||||
{Scope: "*", ConfigType: "*", UserID: "*", Permission: "allow"},
|
||||
{Scope: "*", ConfigType: "*", UserID: "alice", Permission: "deny"},
|
||||
}
|
||||
if got := evalPermRows(rows, "any", "any", "alice"); got {
|
||||
t.Error("individual deny should win over group allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalPermRows_IndividualAllowWinsOverGroupDeny(t *testing.T) {
|
||||
rows := []permRow{
|
||||
{Scope: "*", ConfigType: "*", UserID: "*", Permission: "deny"},
|
||||
{Scope: "*", ConfigType: "*", UserID: "alice", Permission: "allow"},
|
||||
}
|
||||
if got := evalPermRows(rows, "any", "any", "alice"); !got {
|
||||
t.Error("individual allow should win over group deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalPermRows_GroupAllowWhenNoIndividual(t *testing.T) {
|
||||
rows := []permRow{
|
||||
{Scope: "*", ConfigType: "*", UserID: "*", Permission: "allow"},
|
||||
}
|
||||
if got := evalPermRows(rows, "any", "any", "bob"); !got {
|
||||
t.Error("group allow should apply when no individual rule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalPermRows_GroupDenyWhenNoIndividual(t *testing.T) {
|
||||
rows := []permRow{
|
||||
{Scope: "*", ConfigType: "*", UserID: "*", Permission: "deny"},
|
||||
}
|
||||
if got := evalPermRows(rows, "any", "any", "bob"); got {
|
||||
t.Error("group deny should apply when no individual rule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalPermRows_NoMatchDefaultsDeny(t *testing.T) {
|
||||
rows := []permRow{
|
||||
{Scope: "different", ConfigType: "*", UserID: "alice", Permission: "allow"},
|
||||
}
|
||||
// scope doesn't match → no rule applies → implicit deny.
|
||||
if got := evalPermRows(rows, "other", "any", "alice"); got {
|
||||
t.Error("no matching rule should default to deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalPermRows_ScopeFiltering(t *testing.T) {
|
||||
rows := []permRow{
|
||||
{Scope: "group:*", ConfigType: "*", UserID: "*", Permission: "allow"},
|
||||
}
|
||||
if !evalPermRows(rows, "group:telegram:1", "skill", "u") {
|
||||
t.Error("wildcarded scope should match")
|
||||
}
|
||||
if evalPermRows(rows, "dm:telegram", "skill", "u") {
|
||||
t.Error("wildcarded scope should NOT match different prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalPermRows_ConfigTypeFiltering(t *testing.T) {
|
||||
rows := []permRow{
|
||||
{Scope: "*", ConfigType: "skill", UserID: "u", Permission: "allow"},
|
||||
}
|
||||
if !evalPermRows(rows, "any", "skill", "u") {
|
||||
t.Error("exact config_type match should allow")
|
||||
}
|
||||
if evalPermRows(rows, "any", "other", "u") {
|
||||
t.Error("different config_type should not allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItoa(t *testing.T) {
|
||||
cases := []struct {
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{1, "1"},
|
||||
{42, "42"},
|
||||
{-5, "-5"},
|
||||
{1000, "1000"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := itoa(tc.n); got != tc.want {
|
||||
t.Errorf("itoa(%d) = %q, want %q", tc.n, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- contact_resolve.go: contactResolveCache ---
|
||||
|
||||
func TestContactResolveCache_GetSet(t *testing.T) {
|
||||
c := newContactResolveCache()
|
||||
|
||||
// Miss.
|
||||
if v, ok := c.get("k1"); ok {
|
||||
t.Errorf("expected miss, got %q", v)
|
||||
}
|
||||
|
||||
// Set and hit.
|
||||
c.set("k1", "tenant-user-1")
|
||||
if v, ok := c.get("k1"); !ok || v != "tenant-user-1" {
|
||||
t.Errorf("get after set: got %q ok=%v", v, ok)
|
||||
}
|
||||
|
||||
// Set another key without interfering.
|
||||
c.set("k2", "tenant-user-2")
|
||||
if v, ok := c.get("k2"); !ok || v != "tenant-user-2" {
|
||||
t.Errorf("k2: got %q ok=%v", v, ok)
|
||||
}
|
||||
if v, ok := c.get("k1"); !ok || v != "tenant-user-1" {
|
||||
t.Errorf("k1 after k2 set: got %q ok=%v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactResolveCache_NegativeResult(t *testing.T) {
|
||||
c := newContactResolveCache()
|
||||
// Negative results are cached as empty string with ok=true.
|
||||
c.set("k1", "")
|
||||
v, ok := c.get("k1")
|
||||
if !ok {
|
||||
t.Error("negative cache entry should be present")
|
||||
}
|
||||
if v != "" {
|
||||
t.Errorf("negative v = %q, want empty", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactResolveCache_ExpiredEntry(t *testing.T) {
|
||||
c := newContactResolveCache()
|
||||
// Manually insert a stale entry.
|
||||
c.items["k1"] = contactResolveEntry{
|
||||
tenantUserID: "old-user",
|
||||
fetched: time.Now().Add(-2 * contactResolveCacheTTL),
|
||||
}
|
||||
if _, ok := c.get("k1"); ok {
|
||||
t.Error("expired entry should not be returned as hit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// --- agents_scan_rows.go ---
|
||||
|
||||
func TestAgentShareRow_ToAgentShareData(t *testing.T) {
|
||||
id := uuid.New()
|
||||
aid := uuid.New()
|
||||
created := time.Now()
|
||||
r := agentShareRow{
|
||||
ID: id,
|
||||
AgentID: aid,
|
||||
UserID: "user-1",
|
||||
Role: "editor",
|
||||
GrantedBy: "admin",
|
||||
CreatedAt: created,
|
||||
}
|
||||
got := r.toAgentShareData()
|
||||
if got.ID != id || got.AgentID != aid {
|
||||
t.Errorf("IDs mismatch: %+v", got)
|
||||
}
|
||||
if got.UserID != "user-1" || got.Role != "editor" || got.GrantedBy != "admin" {
|
||||
t.Errorf("fields: %+v", got)
|
||||
}
|
||||
if !got.CreatedAt.Equal(created) {
|
||||
t.Errorf("CreatedAt mismatch: got %v want %v", got.CreatedAt, created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInstanceRow_ToUserInstanceData(t *testing.T) {
|
||||
first := "2026-04-11T00:00:00Z"
|
||||
last := "2026-04-11T12:00:00Z"
|
||||
r := userInstanceRow{
|
||||
UserID: "u1",
|
||||
FirstSeenAt: &first,
|
||||
LastSeenAt: &last,
|
||||
FileCount: 3,
|
||||
MetadataRaw: []byte(`{"locale":"vi","tier":"pro"}`),
|
||||
}
|
||||
got := r.toUserInstanceData()
|
||||
if got.UserID != "u1" || got.FileCount != 3 {
|
||||
t.Errorf("basic fields: %+v", got)
|
||||
}
|
||||
if got.Metadata["locale"] != "vi" || got.Metadata["tier"] != "pro" {
|
||||
t.Errorf("metadata: %+v", got.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInstanceRow_EmptyMetadataRaw(t *testing.T) {
|
||||
r := userInstanceRow{UserID: "u1", FileCount: 0}
|
||||
got := r.toUserInstanceData()
|
||||
if got.Metadata != nil {
|
||||
t.Errorf("expected nil metadata, got %v", got.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInstanceRow_MalformedMetadata(t *testing.T) {
|
||||
// Malformed JSON is swallowed (errcheck disabled), should not panic.
|
||||
r := userInstanceRow{MetadataRaw: []byte(`{broken`)}
|
||||
got := r.toUserInstanceData()
|
||||
_ = got // zero-valued metadata is fine
|
||||
}
|
||||
|
||||
// --- memory_scan_rows.go ---
|
||||
|
||||
func TestDocumentInfoRow_ToDocumentInfo(t *testing.T) {
|
||||
updated := time.Unix(1700000000, 0)
|
||||
uid := "user-42"
|
||||
r := documentInfoRow{
|
||||
AgentID: "agent-1",
|
||||
Path: "/mem/doc.md",
|
||||
Hash: "abc",
|
||||
UserID: &uid,
|
||||
UpdatedAt: updated,
|
||||
}
|
||||
got := r.toDocumentInfo()
|
||||
if got.AgentID != "agent-1" || got.Path != "/mem/doc.md" || got.Hash != "abc" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if got.UserID != "user-42" {
|
||||
t.Errorf("UserID = %q, want user-42", got.UserID)
|
||||
}
|
||||
if got.UpdatedAt != updated.UnixMilli() {
|
||||
t.Errorf("UpdatedAt = %d, want %d", got.UpdatedAt, updated.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentInfoRow_NilUserID(t *testing.T) {
|
||||
r := documentInfoRow{AgentID: "a", Path: "p", Hash: "h", UpdatedAt: time.Now()}
|
||||
got := r.toDocumentInfo()
|
||||
if got.UserID != "" {
|
||||
t.Errorf("nil userID should produce empty string, got %q", got.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentDetailRow_ToDocumentDetail(t *testing.T) {
|
||||
created := time.Unix(1700000000, 0)
|
||||
updated := time.Unix(1700000100, 0)
|
||||
uid := "user-7"
|
||||
r := documentDetailRow{
|
||||
Path: "/p.md", Content: "body", Hash: "h",
|
||||
UserID: &uid, CreatedAt: created, UpdatedAt: updated,
|
||||
ChunkCount: 5, EmbeddedCount: 3,
|
||||
}
|
||||
got := r.toDocumentDetail()
|
||||
if got.Path != "/p.md" || got.Content != "body" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if got.UserID != "user-7" {
|
||||
t.Errorf("UserID = %q", got.UserID)
|
||||
}
|
||||
if got.ChunkCount != 5 || got.EmbeddedCount != 3 {
|
||||
t.Errorf("counts: %+v", got)
|
||||
}
|
||||
if got.CreatedAt != created.UnixMilli() || got.UpdatedAt != updated.UnixMilli() {
|
||||
t.Errorf("timestamps: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentDetailRow_NilUserID(t *testing.T) {
|
||||
r := documentDetailRow{Path: "p"}
|
||||
got := r.toDocumentDetail()
|
||||
if got.UserID != "" {
|
||||
t.Errorf("UserID = %q, want empty", got.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkInfoRow_ToChunkInfo(t *testing.T) {
|
||||
r := chunkInfoRow{ID: "chunk-1", StartLine: 10, EndLine: 20, TextPreview: "preview", HasEmbedding: true}
|
||||
got := r.toChunkInfo()
|
||||
if got != (store.ChunkInfo{
|
||||
ID: "chunk-1", StartLine: 10, EndLine: 20, TextPreview: "preview", HasEmbedding: true,
|
||||
}) {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoredChunkRow_ToScoredChunk(t *testing.T) {
|
||||
uid := "u1"
|
||||
r := scoredChunkRow{
|
||||
Path: "p", StartLine: 1, EndLine: 5, Text: "text", UserID: &uid, Score: 0.7,
|
||||
}
|
||||
got := r.toScoredChunk()
|
||||
if got.Path != "p" || got.Score != 0.7 {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if got.UserID == nil || *got.UserID != "u1" {
|
||||
t.Errorf("UserID pointer lost: %v", got.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodicSummaryRow_ToEpisodicSummary(t *testing.T) {
|
||||
created := time.Now()
|
||||
tid := uuid.New()
|
||||
agentID := uuid.New()
|
||||
r := episodicSummaryRow{
|
||||
ID: uuid.New().String(),
|
||||
TenantID: tid.String(),
|
||||
AgentID: agentID.String(),
|
||||
UserID: "u1",
|
||||
SessionKey: "sess-1",
|
||||
Summary: "did X",
|
||||
KeyTopics: pq.StringArray{"topic-a", "topic-b"},
|
||||
TurnCount: 5, TokenCount: 100,
|
||||
L0Abstract: "abs",
|
||||
SourceID: "src-1", SourceType: "session",
|
||||
CreatedAt: created,
|
||||
RecallCount: 2, RecallScore: 0.8,
|
||||
}
|
||||
got := r.toEpisodicSummary()
|
||||
if got.UserID != "u1" || got.SessionKey != "sess-1" || got.Summary != "did X" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if len(got.KeyTopics) != 2 || got.KeyTopics[0] != "topic-a" {
|
||||
t.Errorf("topics = %v", got.KeyTopics)
|
||||
}
|
||||
if got.TurnCount != 5 || got.TokenCount != 100 {
|
||||
t.Errorf("counts: %+v", got)
|
||||
}
|
||||
if got.RecallCount != 2 || got.RecallScore != 0.8 {
|
||||
t.Errorf("recall: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodicScoredRow_ToEpisodicScored(t *testing.T) {
|
||||
created := time.Now()
|
||||
r := episodicScoredRow{
|
||||
ID: "ep-1", SessionKey: "s-1", L0Abstract: "abs", Score: 0.5, CreatedAt: created,
|
||||
}
|
||||
got := r.toEpisodicScored()
|
||||
if got.id != "ep-1" || got.sessionKey != "s-1" || got.l0 != "abs" || got.score != 0.5 {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if !got.createdAt.Equal(created) {
|
||||
t.Errorf("createdAt mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// --- sessions_scan_rows.go ---
|
||||
|
||||
func TestSessionListRow_ToSessionInfo(t *testing.T) {
|
||||
label := "my-session"
|
||||
ch := "telegram"
|
||||
uid := "u1"
|
||||
meta := `{"topic":"support","locale":"vi"}`
|
||||
created := time.Unix(1700000000, 0)
|
||||
updated := time.Unix(1700000100, 0)
|
||||
r := sessionListRow{
|
||||
Key: "session-key",
|
||||
MsgsJSON: []byte(`[]`),
|
||||
Created: created, Updated: updated,
|
||||
Label: &label, Channel: &ch, UserID: &uid,
|
||||
MetaJSON: []byte(meta),
|
||||
}
|
||||
got := r.toSessionInfo(7)
|
||||
if got.Key != "session-key" || got.MessageCount != 7 {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if got.Label != "my-session" || got.Channel != "telegram" || got.UserID != "u1" {
|
||||
t.Errorf("deref fields: %+v", got)
|
||||
}
|
||||
if got.Metadata["topic"] != "support" || got.Metadata["locale"] != "vi" {
|
||||
t.Errorf("metadata: %v", got.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionListRow_NilPointers(t *testing.T) {
|
||||
r := sessionListRow{Key: "k"}
|
||||
got := r.toSessionInfo(0)
|
||||
if got.Label != "" || got.Channel != "" || got.UserID != "" {
|
||||
t.Errorf("nil deref should empty: %+v", got)
|
||||
}
|
||||
if got.Metadata != nil {
|
||||
t.Errorf("empty metadata should be nil, got %v", got.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPagedRow_ToSessionInfo(t *testing.T) {
|
||||
label := "L"
|
||||
r := sessionPagedRow{
|
||||
Key: "k", MsgCount: 10,
|
||||
Created: time.Now(), Updated: time.Now(),
|
||||
Label: &label,
|
||||
}
|
||||
got := r.toSessionInfo()
|
||||
if got.Key != "k" || got.MessageCount != 10 || got.Label != "L" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRichRow_ToSessionInfoRich(t *testing.T) {
|
||||
model := "gpt-4o"
|
||||
provider := "openai"
|
||||
r := sessionRichRow{
|
||||
Key: "k",
|
||||
MsgCount: 3,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
Model: &model,
|
||||
Provider: &provider,
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
AgentName: "assistant",
|
||||
EstimatedTokens: 300,
|
||||
ContextWindow: 200000,
|
||||
CompactionCount: 1,
|
||||
}
|
||||
got := r.toSessionInfoRich()
|
||||
if got.Model != "gpt-4o" || got.Provider != "openai" {
|
||||
t.Errorf("deref: %+v", got)
|
||||
}
|
||||
if got.InputTokens != 1000 || got.OutputTokens != 500 {
|
||||
t.Errorf("tokens: %+v", got)
|
||||
}
|
||||
if got.AgentName != "assistant" || got.EstimatedTokens != 300 {
|
||||
t.Errorf("agent/est: %+v", got)
|
||||
}
|
||||
if got.ContextWindow != 200000 || got.CompactionCount != 1 {
|
||||
t.Errorf("window: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- knowledge_graph_scan_rows.go ---
|
||||
|
||||
func TestEntityRow_ToEntity(t *testing.T) {
|
||||
created := time.Unix(1700000000, 0)
|
||||
r := entityRow{
|
||||
ID: "e1",
|
||||
AgentID: "a1",
|
||||
UserID: "u1",
|
||||
ExternalID: "ext-1",
|
||||
Name: "Alice",
|
||||
EntityType: "Person",
|
||||
Description: "friend",
|
||||
Properties: json.RawMessage(`{"age":"30","role":"dev"}`),
|
||||
SourceID: "src-1",
|
||||
Confidence: 0.9,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
got := r.toEntity()
|
||||
if got.ID != "e1" || got.Name != "Alice" || got.EntityType != "Person" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if got.Properties["age"] != "30" {
|
||||
t.Errorf("properties age = %v", got.Properties["age"])
|
||||
}
|
||||
if got.Properties["role"] != "dev" {
|
||||
t.Errorf("properties role = %v", got.Properties["role"])
|
||||
}
|
||||
if got.CreatedAt != created.UnixMilli() {
|
||||
t.Errorf("createdAt: %d", got.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityRow_EmptyProperties(t *testing.T) {
|
||||
r := entityRow{ID: "e1", Name: "n"}
|
||||
got := r.toEntity()
|
||||
if got.Properties != nil {
|
||||
t.Errorf("empty props should be nil map, got %v", got.Properties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityTemporalRow_PreservesTemporal(t *testing.T) {
|
||||
vf := time.Unix(1700000000, 0)
|
||||
vu := time.Unix(1700000100, 0)
|
||||
r := entityTemporalRow{
|
||||
entityRow: entityRow{ID: "e1", Name: "n"},
|
||||
ValidFrom: &vf,
|
||||
ValidUntil: &vu,
|
||||
}
|
||||
got := r.toEntity()
|
||||
if got.ValidFrom == nil || !got.ValidFrom.Equal(vf) {
|
||||
t.Errorf("ValidFrom lost: %v", got.ValidFrom)
|
||||
}
|
||||
if got.ValidUntil == nil || !got.ValidUntil.Equal(vu) {
|
||||
t.Errorf("ValidUntil lost: %v", got.ValidUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationRow_ToRelation(t *testing.T) {
|
||||
created := time.Unix(1700000000, 0)
|
||||
r := relationRow{
|
||||
ID: "r1", AgentID: "a", UserID: "u",
|
||||
SourceEntityID: "s", RelationType: "KNOWS", TargetEntityID: "t",
|
||||
Confidence: 0.8,
|
||||
Properties: json.RawMessage(`{"since":"2020","strength":"strong"}`),
|
||||
CreatedAt: created,
|
||||
}
|
||||
got := r.toRelation()
|
||||
if got.RelationType != "KNOWS" || got.SourceEntityID != "s" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if got.Properties["since"] != "2020" {
|
||||
t.Errorf("properties: %v", got.Properties)
|
||||
}
|
||||
if got.CreatedAt != created.UnixMilli() {
|
||||
t.Errorf("createdAt: %d", got.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationExportRow_PreservesTemporal(t *testing.T) {
|
||||
vf := time.Unix(1700000000, 0)
|
||||
r := relationExportRow{
|
||||
relationRow: relationRow{ID: "r1", RelationType: "T"},
|
||||
ValidFrom: &vf,
|
||||
}
|
||||
got := r.toRelation()
|
||||
if got.ValidFrom == nil || !got.ValidFrom.Equal(vf) {
|
||||
t.Errorf("ValidFrom lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraversalRow_ToTraversalResult(t *testing.T) {
|
||||
r := traversalRow{
|
||||
entityRow: entityRow{ID: "e1", Name: "n"},
|
||||
Depth: 3,
|
||||
Path: pq.StringArray{"e0", "e1"},
|
||||
Via: "KNOWS",
|
||||
}
|
||||
got := r.toTraversalResult()
|
||||
if got.Depth != 3 || got.Via != "KNOWS" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if len(got.Path) != 2 {
|
||||
t.Errorf("path = %v", got.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupCandidateRow_ToDedupCandidate(t *testing.T) {
|
||||
now := time.Now()
|
||||
r := dedupCandidateRow{
|
||||
ID: "dc-1", Similarity: 0.95, Status: "pending", CreatedAt: now,
|
||||
AID: "ea", AName: "Alice", AType: "Person", AProps: json.RawMessage(`{"k":"v"}`),
|
||||
BID: "eb", BName: "Bob", BType: "Person",
|
||||
ACreatedAt: now, AUpdatedAt: now, BCreatedAt: now, BUpdatedAt: now,
|
||||
}
|
||||
got := r.toDedupCandidate()
|
||||
if got.ID != "dc-1" || got.Similarity != 0.95 {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if got.EntityA.Name != "Alice" || got.EntityB.Name != "Bob" {
|
||||
t.Errorf("entities: %+v / %+v", got.EntityA, got.EntityB)
|
||||
}
|
||||
if got.EntityA.Properties["k"] != "v" {
|
||||
t.Errorf("A props: %v", got.EntityA.Properties)
|
||||
}
|
||||
}
|
||||
|
||||
// --- mcp_scan_rows.go ---
|
||||
|
||||
func TestMCPAccessRequestRow_ToMCPAccessRequest(t *testing.T) {
|
||||
id := uuid.New()
|
||||
serverID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
uid := "u1"
|
||||
reviewedBy := "admin"
|
||||
reviewedAt := time.Now()
|
||||
reviewNote := "looks good"
|
||||
r := mcpAccessRequestRow{
|
||||
ID: id, ServerID: serverID, AgentID: &agentID,
|
||||
UserID: &uid,
|
||||
Scope: "read", Status: "approved", Reason: "need access",
|
||||
ToolAllow: []byte(`["read_file"]`),
|
||||
RequestedBy: "dev1",
|
||||
ReviewedBy: &reviewedBy,
|
||||
ReviewedAt: &reviewedAt,
|
||||
ReviewNote: &reviewNote,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
got := r.toMCPAccessRequest()
|
||||
if got.ID != id || got.ServerID != serverID {
|
||||
t.Errorf("IDs mismatch")
|
||||
}
|
||||
if got.AgentID == nil || *got.AgentID != agentID {
|
||||
t.Errorf("AgentID: %v", got.AgentID)
|
||||
}
|
||||
if got.UserID != "u1" || got.ReviewedBy != "admin" || got.ReviewNote != "looks good" {
|
||||
t.Errorf("deref: %+v", got)
|
||||
}
|
||||
if string(got.ToolAllow) != `["read_file"]` {
|
||||
t.Errorf("ToolAllow = %s", got.ToolAllow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAccessRequestRow_NilOptionals(t *testing.T) {
|
||||
r := mcpAccessRequestRow{
|
||||
ID: uuid.New(), ServerID: uuid.New(),
|
||||
Scope: "r", Status: "pending", Reason: "why",
|
||||
RequestedBy: "u",
|
||||
}
|
||||
got := r.toMCPAccessRequest()
|
||||
if got.AgentID != nil {
|
||||
t.Errorf("AgentID should be nil, got %v", got.AgentID)
|
||||
}
|
||||
if got.UserID != "" || got.ReviewedBy != "" || got.ReviewNote != "" {
|
||||
t.Errorf("deref should all be empty: %+v", got)
|
||||
}
|
||||
if got.ToolAllow != nil {
|
||||
t.Errorf("empty ToolAllow should be nil, got %v", got.ToolAllow)
|
||||
}
|
||||
}
|
||||
|
||||
// --- cron_run_log_scan_rows.go ---
|
||||
|
||||
func TestCronRunLogRow_ToCronRunLogEntry(t *testing.T) {
|
||||
ranAt := time.Unix(1700000000, 0)
|
||||
errMsg := "boom"
|
||||
sum := "ok"
|
||||
r := cronRunLogRow{
|
||||
JobID: uuid.New(), Status: "success",
|
||||
Error: &errMsg, Summary: &sum,
|
||||
RanAt: ranAt, DurationMS: 500,
|
||||
InputTokens: 100, OutputTokens: 50,
|
||||
}
|
||||
got := r.toCronRunLogEntry()
|
||||
if got.JobID != r.JobID.String() {
|
||||
t.Errorf("JobID")
|
||||
}
|
||||
if got.Status != "success" || got.Error != "boom" || got.Summary != "ok" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if got.Ts != ranAt.UnixMilli() {
|
||||
t.Errorf("Ts = %d", got.Ts)
|
||||
}
|
||||
if got.DurationMS != 500 {
|
||||
t.Errorf("DurationMS = %d", got.DurationMS)
|
||||
}
|
||||
if got.InputTokens != 100 || got.OutputTokens != 50 {
|
||||
t.Errorf("tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronRunLogRow_NilErrorAndSummary(t *testing.T) {
|
||||
r := cronRunLogRow{JobID: uuid.New(), Status: "ok", RanAt: time.Now()}
|
||||
got := r.toCronRunLogEntry()
|
||||
if got.Error != "" || got.Summary != "" {
|
||||
t.Errorf("nil pointers should deref empty: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- skills helpers (parseDepsColumn, parseFrontmatterAuthor, marshalFrontmatter, buildSkillInfo) ---
|
||||
|
||||
func TestParseDepsColumn(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw []byte
|
||||
want []string
|
||||
}{
|
||||
{"empty returns nil", nil, nil},
|
||||
{"valid missing list", []byte(`{"missing":["tool1","tool2"]}`), []string{"tool1", "tool2"}},
|
||||
{"empty missing list", []byte(`{"missing":[]}`), nil},
|
||||
{"no missing key", []byte(`{"other":"x"}`), nil},
|
||||
{"malformed JSON", []byte(`{broken`), nil},
|
||||
{"null", []byte(`null`), nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parseDepsColumn(tc.raw)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d (got %v)", len(got), len(tc.want), got)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("[%d] got %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatterAuthor(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw []byte
|
||||
want string
|
||||
}{
|
||||
{"empty returns empty", nil, ""},
|
||||
{"valid author", []byte(`{"author":"Alice"}`), "Alice"},
|
||||
{"no author key", []byte(`{"title":"X"}`), ""},
|
||||
{"malformed", []byte(`{broken`), ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parseFrontmatterAuthor(tc.raw)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalFrontmatter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fm map[string]string
|
||||
want string
|
||||
}{
|
||||
{"nil returns empty object", nil, "{}"},
|
||||
{"empty map returns empty object", map[string]string{}, "{}"},
|
||||
{"single key", map[string]string{"author": "Bob"}, `{"author":"Bob"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := string(marshalFrontmatter(tc.fm))
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSkillInfo(t *testing.T) {
|
||||
desc := "a skill"
|
||||
id := uuid.New().String()
|
||||
// With nil filePath → BaseDir computed from baseDir/slug/version.
|
||||
info := buildSkillInfo(id, "TestSkill", "test-skill", &desc, 3, "/skills", nil)
|
||||
if info.ID != id || info.Name != "TestSkill" || info.Slug != "test-skill" {
|
||||
t.Errorf("%+v", info)
|
||||
}
|
||||
if info.BaseDir != "/skills/test-skill/3" {
|
||||
t.Errorf("BaseDir = %q", info.BaseDir)
|
||||
}
|
||||
if info.Path != "/skills/test-skill/3/SKILL.md" {
|
||||
t.Errorf("Path = %q", info.Path)
|
||||
}
|
||||
if info.Description != "a skill" {
|
||||
t.Errorf("Description = %q", info.Description)
|
||||
}
|
||||
if info.Source != "managed" {
|
||||
t.Errorf("Source = %q", info.Source)
|
||||
}
|
||||
if info.Version != 3 {
|
||||
t.Errorf("Version = %d", info.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSkillInfo_FilePathOverridesBaseDir(t *testing.T) {
|
||||
fp := "/custom/path/to/skill"
|
||||
info := buildSkillInfo("id", "n", "s", nil, 1, "/ignored", &fp)
|
||||
if info.BaseDir != "/custom/path/to/skill" {
|
||||
t.Errorf("BaseDir should honor filePath override: %q", info.BaseDir)
|
||||
}
|
||||
if info.Path != "/custom/path/to/skill/SKILL.md" {
|
||||
t.Errorf("Path = %q", info.Path)
|
||||
}
|
||||
if info.Description != "" {
|
||||
t.Errorf("nil desc should be empty: %q", info.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSkillInfo_EmptyFilePathFallsBack(t *testing.T) {
|
||||
empty := ""
|
||||
info := buildSkillInfo("id", "n", "slug", nil, 2, "/base", &empty)
|
||||
// Empty string filePath still falls back to baseDir path.
|
||||
if info.BaseDir != "/base/slug/2" {
|
||||
t.Errorf("empty filePath should fall back, got %q", info.BaseDir)
|
||||
}
|
||||
}
|
||||
|
||||
// --- skillInfoRow + customSkillExportRow ---
|
||||
|
||||
func TestSkillInfoRow_ToSkillInfo(t *testing.T) {
|
||||
desc := "d"
|
||||
r := skillInfoRow{
|
||||
ID: uuid.New(), Name: "n", Slug: "s", Desc: &desc,
|
||||
Visibility: "public",
|
||||
Tags: pq.StringArray{"tag1", "tag2"},
|
||||
Version: 1, IsSystem: true, Status: "active", Enabled: true,
|
||||
DepsRaw: []byte(`{"missing":["m1"]}`),
|
||||
}
|
||||
info := r.toSkillInfo("/base")
|
||||
if info.Visibility != "public" || info.Status != "active" || !info.Enabled || !info.IsSystem {
|
||||
t.Errorf("%+v", info)
|
||||
}
|
||||
if len(info.Tags) != 2 || info.Tags[0] != "tag1" {
|
||||
t.Errorf("tags: %v", info.Tags)
|
||||
}
|
||||
if len(info.MissingDeps) != 1 || info.MissingDeps[0] != "m1" {
|
||||
t.Errorf("missingDeps: %v", info.MissingDeps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInfoRowWithFrontmatter_ToSkillInfo(t *testing.T) {
|
||||
desc := "d"
|
||||
r := skillInfoRowWithFrontmatter{
|
||||
skillInfoRow: skillInfoRow{
|
||||
ID: uuid.New(), Name: "n", Slug: "s", Desc: &desc,
|
||||
Visibility: "private", Version: 1,
|
||||
},
|
||||
FmRaw: []byte(`{"author":"Alice"}`),
|
||||
}
|
||||
info := r.toSkillInfo("/base")
|
||||
if info.Author != "Alice" {
|
||||
t.Errorf("Author = %q", info.Author)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomSkillExportRow_ToCustomSkillExport(t *testing.T) {
|
||||
id := uuid.New()
|
||||
desc := "a skill"
|
||||
fp := "/skills/test"
|
||||
r := customSkillExportRow{
|
||||
ID: id, Name: "n", Slug: "s", Description: &desc,
|
||||
Visibility: "public", Version: 2,
|
||||
FmRaw: []byte(`{"author":"Alice"}`),
|
||||
Tags: pq.StringArray{"a", "b"},
|
||||
DepsRaw: []byte(`{"missing":[]}`),
|
||||
FilePath: &fp,
|
||||
}
|
||||
got := r.toCustomSkillExport()
|
||||
if got.ID != id.String() || got.Name != "n" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
if got.Description == nil || *got.Description != "a skill" {
|
||||
t.Errorf("Description: %v", got.Description)
|
||||
}
|
||||
if len(got.Tags) != 2 {
|
||||
t.Errorf("tags: %v", got.Tags)
|
||||
}
|
||||
if string(got.Frontmatter) != `{"author":"Alice"}` {
|
||||
t.Errorf("Frontmatter: %s", got.Frontmatter)
|
||||
}
|
||||
if got.FilePath != "/skills/test" {
|
||||
t.Errorf("FilePath = %q", got.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomSkillExportRow_EmptyOptionals(t *testing.T) {
|
||||
r := customSkillExportRow{ID: uuid.New(), Name: "n"}
|
||||
got := r.toCustomSkillExport()
|
||||
if got.Frontmatter != nil || got.Deps != nil {
|
||||
t.Errorf("empty raws should produce nil: %+v", got)
|
||||
}
|
||||
if got.FilePath != "" {
|
||||
t.Errorf("nil FilePath should empty, got %q", got.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
// --- mergeEpisodicScores ---
|
||||
|
||||
func TestMergeEpisodicScores_WeightingAndMerge(t *testing.T) {
|
||||
fts := []episodicScored{
|
||||
{id: "a", sessionKey: "s-a", l0: "A", score: 1.0},
|
||||
{id: "b", sessionKey: "s-b", l0: "B", score: 0.5},
|
||||
}
|
||||
vec := []episodicScored{
|
||||
{id: "a", sessionKey: "s-a", l0: "A", score: 0.8},
|
||||
{id: "c", sessionKey: "s-c", l0: "C", score: 0.9},
|
||||
}
|
||||
merged := mergeEpisodicScores(fts, vec, 0.5, 0.5)
|
||||
if len(merged) != 3 {
|
||||
t.Fatalf("len = %d, want 3 (a+b+c unique)", len(merged))
|
||||
}
|
||||
byID := make(map[string]float64)
|
||||
for _, r := range merged {
|
||||
byID[r.id] = r.score
|
||||
}
|
||||
// a: in both → 1.0*0.5 + 0.8*0.5 = 0.5 + 0.4 = 0.9
|
||||
if got := byID["a"]; got < 0.89 || got > 0.91 {
|
||||
t.Errorf("a score = %v, want ~0.9", got)
|
||||
}
|
||||
// b: fts only → 0.5*0.5 = 0.25
|
||||
if got := byID["b"]; got < 0.24 || got > 0.26 {
|
||||
t.Errorf("b score = %v, want ~0.25", got)
|
||||
}
|
||||
// c: vec only → 0.9*0.5 = 0.45
|
||||
if got := byID["c"]; got < 0.44 || got > 0.46 {
|
||||
t.Errorf("c score = %v, want ~0.45", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEpisodicScores_EmptyInputs(t *testing.T) {
|
||||
if got := mergeEpisodicScores(nil, nil, 0.5, 0.5); got != nil {
|
||||
t.Errorf("both empty should produce nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- hybridMerge (memory_search.go) ---
|
||||
|
||||
func TestHybridMerge_PersonalBoostOverGlobal(t *testing.T) {
|
||||
uid := "u1"
|
||||
fts := []scoredChunk{
|
||||
{Path: "p", StartLine: 1, EndLine: 5, Text: "global text", Score: 1.0},
|
||||
{Path: "p", StartLine: 1, EndLine: 5, Text: "personal text", UserID: &uid, Score: 1.0},
|
||||
}
|
||||
got := hybridMerge(fts, nil, 1.0, 1.0, "u1")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1 (dedup on path+startLine)", len(got))
|
||||
}
|
||||
if got[0].Scope != "personal" {
|
||||
t.Errorf("scope = %q, want personal", got[0].Scope)
|
||||
}
|
||||
if got[0].Snippet != "personal text" {
|
||||
t.Errorf("snippet = %q, want personal text (user copy wins)", got[0].Snippet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridMerge_SortsByScoreDescending(t *testing.T) {
|
||||
fts := []scoredChunk{
|
||||
{Path: "low", StartLine: 1, EndLine: 2, Text: "l", Score: 0.1},
|
||||
{Path: "high", StartLine: 1, EndLine: 2, Text: "h", Score: 0.9},
|
||||
}
|
||||
got := hybridMerge(fts, nil, 1.0, 1.0, "")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Path != "high" || got[1].Path != "low" {
|
||||
t.Errorf("sort order: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridMerge_FTSPlusVectorAddScores(t *testing.T) {
|
||||
fts := []scoredChunk{{Path: "p", StartLine: 1, EndLine: 5, Text: "t", Score: 0.4}}
|
||||
vec := []scoredChunk{{Path: "p", StartLine: 1, EndLine: 5, Text: "t", Score: 0.6}}
|
||||
got := hybridMerge(fts, vec, 1.0, 1.0, "")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
// 0.4*1.0 + 0.6*1.0 = 1.0
|
||||
if got[0].Score < 0.99 || got[0].Score > 1.01 {
|
||||
t.Errorf("score = %v, want 1.0", got[0].Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridMerge_EmptyInputs(t *testing.T) {
|
||||
got := hybridMerge(nil, nil, 1.0, 1.0, "")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"internal/cache": 96.875,
|
||||
"internal/channels": 26.524390243902438,
|
||||
"internal/channels/discord": 27.680311890838208,
|
||||
"internal/channels/facebook": 23.076923076923077,
|
||||
"internal/channels/facebook": 81.8,
|
||||
"internal/channels/feishu": 0,
|
||||
"internal/channels/media": 9.174311926605505,
|
||||
"internal/channels/pancake": 55.319148936170215,
|
||||
@@ -48,7 +48,7 @@
|
||||
"internal/skills": 37.5,
|
||||
"internal/store": 25.880281690140844,
|
||||
"internal/store/base": 95.95959595959596,
|
||||
"internal/store/pg": 1.2770943175161102,
|
||||
"internal/store/pg": 3.45,
|
||||
"internal/tasks": 55.4140127388535,
|
||||
"internal/testutil": 0,
|
||||
"internal/tokencount": 77.17391304347827,
|
||||
|
||||
Reference in new issue
Block a user