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:
viettranx committed 2026-04-11 21:47:22 +07:00
1 parent 20de0e332e
commit f2c43c3849
6 files changed
+2230 -3

No files matched your search

+4 -1
View File
@@ -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)
}
}
+677
View File
@@ -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))
}
}
+177
View File
@@ -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")
}
}
+801
View File
@@ -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))
}
}
+2 -2
View File
@@ -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,