Files
viettranx 0c44149fad test: speed up retry/cron/facebook tests, drop coverage ratchet gate
Slow tests were dominating CI feedback time and AI dev loop because they
waited through real exponential backoffs and 1s ticker intervals.
Test-only override pattern keeps production behavior 100% identical.

Speed wins (no-race wall-clock per package):
- internal/vault            16.3s -> 0.6s   (-15.7s)
- internal/cron             11.7s -> 1.5s   (-10.2s)
- internal/channels/facebook 6.3s -> 3.0s   (-3.3s)
- Full -race ./... suite     90s+ -> 51s

Changes:
- vault: new fastBackoffsForTest(t) helper overrides enrichRetryBackoffs
  + enrichRetryTimeouts to 1ms in 3 retry tests; drop 2 duplicate tests
  (FirstAttemptSuccess, MaxRetriesConstant)
- cron: extract runLoopTickInterval as package var (default 1s); test-only
  setFastTick(t) helper shortens to 20ms so 6 scheduler tests no longer
  sleep 1.5s each waiting for a tick
- facebook: extract graphBackoffBase as package var (default 1s); newFakeGraph
  helper shortens to 1ms so HTTP retry tests don't burn 6s of real waits

Coverage ratchet removed:
- Delete scripts/check_coverage.go + scripts/coverage_thresholds.json
- Remove "Coverage ratchet gate" CI step
- Keep coverage profile + go tool cover summary as informational only
- Philosophy: signal over coverage %. Forced tests to bump % were the
  root cause of the slowness this commit unwinds.

Production behavior unchanged. Coverage profile shows isolated package
coverage matches prior thresholds (vault 27.4%, cron 73.7%, facebook 81.9%).
2026-04-11 23:53:07 +07:00

575 lines
18 KiB
Go

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).
// Backoff base is reduced to 1ms so retry tests don't burn ~6s on real
// exponential waits (1s, 2s, 4s). Production behavior is unchanged.
func newFakeGraph(t *testing.T, handler http.Handler) *GraphClient {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
swapGraphBase(t, srv.URL)
savedBackoff := graphBackoffBase
graphBackoffBase = time.Millisecond
t.Cleanup(func() { graphBackoffBase = savedBackoff })
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)
}
}