Files
kandev/apps/backend/internal/github/ratelimit_test.go
T

231 lines
7.3 KiB
Go

package github
import (
"context"
"net/http"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
func newTestTrackerLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
if err != nil {
t.Fatalf("logger: %v", err)
}
return log
}
// captureBus collects events published to a single subject so tests can
// assert on transitions without racing.
type captureBus struct {
mu sync.Mutex
events []*bus.Event
inner bus.EventBus
}
func newCaptureBus(t *testing.T, subject string) *captureBus {
t.Helper()
log := newTestTrackerLogger(t)
cb := &captureBus{inner: bus.NewMemoryEventBus(log)}
if _, err := cb.inner.Subscribe(subject, func(_ context.Context, e *bus.Event) error {
cb.mu.Lock()
cb.events = append(cb.events, e)
cb.mu.Unlock()
return nil
}); err != nil {
t.Fatalf("subscribe: %v", err)
}
return cb
}
func (c *captureBus) Publish(ctx context.Context, subject string, event *bus.Event) error {
return c.inner.Publish(ctx, subject, event)
}
func (c *captureBus) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) {
return c.inner.Subscribe(subject, handler)
}
func (c *captureBus) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) {
return c.inner.QueueSubscribe(subject, queue, handler)
}
func (c *captureBus) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) {
return c.inner.Request(ctx, subject, event, timeout)
}
func (c *captureBus) Close() { c.inner.Close() }
func (c *captureBus) IsConnected() bool { return c.inner.IsConnected() }
// captured returns a snapshot of events captured so far. MemoryEventBus
// dispatches to subscribers synchronously, so callers can read this directly
// after Record() / Publish() returns without polling.
func (c *captureBus) captured() []*bus.Event {
c.mu.Lock()
defer c.mu.Unlock()
return append([]*bus.Event(nil), c.events...)
}
func (c *captureBus) requireCount(t *testing.T, count int, msg string) []*bus.Event {
t.Helper()
evs := c.captured()
if len(evs) != count {
t.Fatalf("%s: expected %d events, got %d", msg, count, len(evs))
}
return evs
}
func TestParseRateHeaders_DecodesAllFields(t *testing.T) {
resp := &http.Response{Header: http.Header{}}
resp.Header.Set("X-RateLimit-Limit", "5000")
resp.Header.Set("X-RateLimit-Remaining", "4242")
resp.Header.Set("X-RateLimit-Reset", "2000000000")
resp.Header.Set("X-RateLimit-Resource", "graphql")
snap, ok := parseRateHeaders(resp, ResourceCore)
if !ok {
t.Fatalf("expected snapshot to be parsed")
}
if snap.Resource != ResourceGraphQL {
t.Errorf("resource: got %q want graphql", snap.Resource)
}
if snap.Limit != 5000 || snap.Remaining != 4242 {
t.Errorf("limit/remaining mismatch: %d / %d", snap.Limit, snap.Remaining)
}
if snap.ResetAt.Unix() != 2000000000 {
t.Errorf("reset_at: got %d want 2000000000", snap.ResetAt.Unix())
}
}
func TestParseRateHeaders_FallsBackToDefaultResource(t *testing.T) {
resp := &http.Response{Header: http.Header{}}
resp.Header.Set("X-RateLimit-Limit", "30")
resp.Header.Set("X-RateLimit-Remaining", "29")
resp.Header.Set("X-RateLimit-Reset", "2000000000")
snap, ok := parseRateHeaders(resp, ResourceSearch)
if !ok || snap.Resource != ResourceSearch {
t.Fatalf("expected default resource search, got %q (ok=%v)", snap.Resource, ok)
}
}
func TestParseRateHeaders_NoHeadersReturnsFalse(t *testing.T) {
resp := &http.Response{Header: http.Header{}}
if _, ok := parseRateHeaders(resp, ResourceCore); ok {
t.Fatalf("expected no snapshot when headers absent")
}
}
func TestRateTracker_RecordEmitsAndTracksExhaustion(t *testing.T) {
log := newTestTrackerLogger(t)
cb := newCaptureBus(t, events.GitHubRateLimitUpdated)
tr := NewRateTracker(cb, log)
future := time.Now().Add(20 * time.Minute)
// First snapshot: healthy. Should publish once (initial update).
tr.Record(RateSnapshot{
Resource: ResourceGraphQL,
Limit: 5000,
Remaining: 4000,
ResetAt: future,
UpdatedAt: time.Now(),
})
if tr.IsExhausted(ResourceGraphQL) {
t.Fatalf("not exhausted yet")
}
// Second snapshot: exhausted -> must publish (transition).
tr.Record(RateSnapshot{
Resource: ResourceGraphQL,
Limit: 5000,
Remaining: 0,
ResetAt: future,
UpdatedAt: time.Now(),
})
if !tr.IsExhausted(ResourceGraphQL) {
t.Fatalf("expected exhausted after Remaining=0 + future reset")
}
if d := tr.WaitDuration(ResourceGraphQL); d <= 0 || d > 21*time.Minute {
t.Fatalf("WaitDuration out of range: %v", d)
}
// Third snapshot: recovered -> publishes recovery transition.
tr.Record(RateSnapshot{
Resource: ResourceGraphQL,
Limit: 5000,
Remaining: 1500,
ResetAt: future,
UpdatedAt: time.Now(),
})
evts := cb.requireCount(t, 3, "expected 3 events (initial, exhausted, recovered)")
// Last two events should carry transition strings.
gotTransitions := []string{}
for _, e := range evts {
payload, ok := e.Data.(*RateLimitUpdatedEvent)
if !ok {
t.Fatalf("unexpected event payload type %T", e.Data)
}
gotTransitions = append(gotTransitions, payload.ExhaustionTransition)
}
if gotTransitions[1] != "exhausted" || gotTransitions[2] != "recovered" {
t.Errorf("transitions = %v, want [* exhausted recovered]", gotTransitions)
}
}
func TestRateTracker_DebouncesNonTransitionUpdates(t *testing.T) {
log := newTestTrackerLogger(t)
cb := newCaptureBus(t, events.GitHubRateLimitUpdated)
tr := NewRateTracker(cb, log)
now := time.Now()
// Two healthy snapshots within the debounce window -> only first emits.
// MemoryEventBus.Publish dispatches synchronously, so reads after Record
// see the final state without sleeping.
tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 4999, ResetAt: now.Add(time.Hour), UpdatedAt: now})
tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 4998, ResetAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Second)})
cb.requireCount(t, 1, "expected 1 debounced event")
// Outside the window -> emits again.
tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 4997, ResetAt: now.Add(time.Hour), UpdatedAt: now.Add(2 * rateUpdateDebounce)})
cb.requireCount(t, 2, "expected second event after debounce window")
}
func TestRateTracker_MarkRateExhaustedSeedsUnknownLimit(t *testing.T) {
log := newTestTrackerLogger(t)
cb := newCaptureBus(t, events.GitHubRateLimitUpdated)
tr := NewRateTracker(cb, log)
tr.markRateExhausted(ResourceGraphQL, time.Time{})
if !tr.IsExhausted(ResourceGraphQL) {
t.Fatalf("expected exhausted after markRateExhausted")
}
snap, ok := tr.Snapshot(ResourceGraphQL)
if !ok {
t.Fatalf("snapshot not stored")
}
if snap.Remaining != 0 {
t.Errorf("Remaining = %d, want 0", snap.Remaining)
}
// Default reset (~1h) means WaitDuration > 0.
if d := tr.WaitDuration(ResourceGraphQL); d <= 0 {
t.Errorf("WaitDuration should be positive, got %v", d)
}
}
func TestRateTracker_NilBusIsNoop(t *testing.T) {
tr := NewRateTracker(nil, nil)
tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 0, ResetAt: time.Now().Add(time.Minute)})
if !tr.IsExhausted(ResourceCore) {
t.Fatalf("tracker should still record when bus is nil")
}
}