package helps import ( "errors" "net/http" "net/http/httptest" "sync" "testing") type cacheKey struct { scope string proxy string} func TestTransportCacheReusesEntriesPerKey(t *testing.T) { cache := NewTransportCache[cacheKey](8) builds := 0 build := func() (*http.Transport, error) { builds++ return &http.Transport{}, nil } first, errFirst := cache.Get(cacheKey{"auth-a", "p1"}, build) if errFirst != nil { t.Fatalf("Get() error = %v", errFirst) } second, errSecond := cache.Get(cacheKey{"auth-a", "p1"}, build) if errSecond != nil { t.Fatalf("Get() second error = %v", errSecond) } if first == nil || first != second { t.Fatalf("expected one cached transport, got %p and %p", first, second) } if builds != 1 { t.Fatalf("build called %d times, want 1", builds) } otherProxy, _ := cache.Get(cacheKey{"auth-a", "p2"}, build) if otherProxy == first { t.Fatal("distinct proxies must not share a transport") } otherScope, _ := cache.Get(cacheKey{"auth-b", "p1"}, build) if otherScope == first { t.Fatal("distinct credential scopes must not share a transport") } if got := cache.Len(); got != 3 { t.Fatalf("cache Len() = %d, want 3", got) }} // TestTransportCacheBoundsEntries is the regression test for unbounded pool growth:// every cached transport owns a connection pool, so churning keys must evict.func TestTransportCacheBoundsEntries(t *testing.T) { const capacity = 4 cache := NewTransportCache[cacheKey](capacity) for i := 0; i < 100; i++ { key := cacheKey{"auth", string(rune('a' + i%97))} if _, err := cache.Get(key, func() (*http.Transport, error) { return &http.Transport{}, nil }); err != nil { t.Fatalf("Get() error = %v", err) } if got := cache.Len(); got > capacity { t.Fatalf("cache grew to %d entries, want at most %d", got, capacity) } }} // TestTransportCacheEvictsLeastRecentlyUsed proves recency is honoured, so a hot// credential is not evicted by a burst of one-off keys.func TestTransportCacheEvictsLeastRecentlyUsed(t *testing.T) { cache := NewTransportCache[cacheKey](2) build := func() (*http.Transport, error) { return &http.Transport{}, nil } hot, _ := cache.Get(cacheKey{"hot", ""}, build) cache.Get(cacheKey{"cold", ""}, build) // Touch hot so cold becomes the least recently used entry. if again, _ := cache.Get(cacheKey{"hot", ""}, build); again != hot { t.Fatal("expected the hot entry to still be cached") } cache.Get(cacheKey{"new", ""}, build) if again, _ := cache.Get(cacheKey{"hot", ""}, build); again != hot { t.Fatal("the most recently used entry must survive eviction") }} // TestTransportCacheDoesNotCacheBuildFailures ensures a transient failure neither// occupies a cache slot nor becomes permanent.func TestTransportCacheDoesNotCacheBuildFailures(t *testing.T) { cache := NewTransportCache[cacheKey](4) key := cacheKey{"auth", "broken"} if _, err := cache.Get(key, func() (*http.Transport, error) { return nil, errors.New("boom") }); err == nil { t.Fatal("expected the build error to be propagated") } if got := cache.Len(); got != 0 { t.Fatalf("a failed build must not occupy a cache slot, Len() = %d", got) } // A build returning (nil, nil) must be reported rather than cached as usable. if _, err := cache.Get(key, func() (*http.Transport, error) { return nil, nil }); err == nil { t.Fatal("expected an error when build returns no transport") } transport, err := cache.Get(key, func() (*http.Transport, error) { return &http.Transport{}, nil }) if err != nil || transport == nil { t.Fatalf("retry after failure must succeed, got (%p, %v)", transport, err) }} func TestTransportCacheConcurrentCallersShareOneInstance(t *testing.T) { cache := NewTransportCache[cacheKey](8) key := cacheKey{"auth-concurrent", "socks5://127.0.0.1:1080"} const callers = 32 results := make([]*http.Transport, callers) var wg sync.WaitGroup wg.Add(callers) for i := 0; i < callers; i++ { go func(index int) { defer wg.Done() results[index], _ = cache.Get(key, func() (*http.Transport, error) { return &http.Transport{}, nil }) }(i) } wg.Wait() for i := 1; i < callers; i++ { if results[i] != results[0] { t.Fatalf("caller %d observed a different transport (%p vs %p)", i, results[i], results[0]) } }} func TestTransportCachePurgeAndNilSafety(t *testing.T) { cache := NewTransportCache[cacheKey](4) build := func() (*http.Transport, error) { return &http.Transport{}, nil } cache.Get(cacheKey{"a", ""}, build) cache.Get(cacheKey{"b", ""}, build) if got := cache.Len(); got != 2 { t.Fatalf("Len() = %d, want 2", got) } cache.Purge() if got := cache.Len(); got != 0 { t.Fatalf("Len() after Purge() = %d, want 0", got) } // The cache stays usable after a purge. if transport, err := cache.Get(cacheKey{"a", ""}, build); err != nil || transport == nil { t.Fatalf("Get() after Purge() = (%p, %v)", transport, err) } var nilCache *TransportCache[cacheKey] if _, err := nilCache.Get(cacheKey{}, build); err == nil { t.Fatal("expected an error from a nil cache") } if got := nilCache.Len(); got != 0 { t.Fatalf("nil cache Len() = %d, want 0", got) } nilCache.Purge() // must not panic if _, err := cache.Get(cacheKey{"nil-build", ""}, nil); err == nil { t.Fatal("expected an error for a nil build function") }} func TestNewTransportCacheDefaultsCapacity(t *testing.T) { for _, capacity := range []int{0, -1} { cache := NewTransportCache[cacheKey](capacity) if cache.capacity != DefaultTransportCacheCapacity { t.Fatalf("NewTransportCache(%d).capacity = %d, want %d", capacity, cache.capacity, DefaultTransportCacheCapacity) } }} func TestTransportCacheCloseKeyAndCloseMatching(t *testing.T) { cache := NewTransportCache[cacheKey](8) build := func() (*http.Transport, error) { return &http.Transport{}, nil } cache.Get(cacheKey{"auth-1", "p1"}, build) cache.Get(cacheKey{"auth-1", "p2"}, build) cache.Get(cacheKey{"auth-2", "p1"}, build) if got := cache.Len(); got != 3 { t.Fatalf("Len() = %d, want 3", got) } // Close non-existent key if closed := cache.CloseKey(cacheKey{"auth-nonexistent", ""}); closed { t.Fatal("expected CloseKey on missing entry to return false") } // Close specific key if closed := cache.CloseKey(cacheKey{"auth-2", "p1"}); !closed { t.Fatal("expected CloseKey on existing entry to return true") } if got := cache.Len(); got != 2 { t.Fatalf("Len() after CloseKey = %d, want 2", got) } // Close matching all auth-1 closedCount := cache.CloseMatching(func(key cacheKey) bool { return key.scope == "auth-1" }) if closedCount != 2 { t.Fatalf("CloseMatching closed %d entries, want 2", closedCount) } if got := cache.Len(); got != 0 { t.Fatalf("Len() after CloseMatching = %d, want 0", got) } // Nil cache safety var nilCache *TransportCache[cacheKey] if nilCache.CloseKey(cacheKey{}) { t.Fatal("expected CloseKey on nil cache to return false") } if nilCache.CloseMatching(func(cacheKey) bool { return true }) != 0 { t.Fatal("expected CloseMatching on nil cache to return 0") }} func TestTransportCacheEvictionClosesIdleConnections(t *testing.T) { cache := NewTransportCache[cacheKey](2) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer srv.Close() build := func() (*http.Transport, error) { return &http.Transport{}, nil } // Fill 2 entries t1, err1 := cache.Get(cacheKey{"auth-1", ""}, build) if err1 != nil { t.Fatal(err1) } _, err2 := cache.Get(cacheKey{"auth-2", ""}, build) if err2 != nil { t.Fatal(err2) } // Make request with t1 to establish an idle connection c1 := &http.Client{Transport: t1} resp, errReq := c1.Get(srv.URL) if errReq != nil { t.Fatal(errReq) } _ = resp.Body.Close() // Push 3rd entry: t1 should be evicted and its idle connections closed _, err3 := cache.Get(cacheKey{"auth-3", ""}, build) if err3 != nil { t.Fatal(err3) } if cache.Contains(cacheKey{"auth-1", ""}) { t.Fatal("expected auth-1 to be evicted from cache") } if !cache.Contains(cacheKey{"auth-2", ""}) || !cache.Contains(cacheKey{"auth-3", ""}) { t.Fatal("expected auth-2 and auth-3 to remain in cache") }}
package helps
import (
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
type cacheKey struct {
scope string
proxy string
}
func TestTransportCacheReusesEntriesPerKey(t *testing.T) {
cache := NewTransportCache[cacheKey](8)
builds := 0
build := func() (*http.Transport, error) {
builds++
return &http.Transport{}, nil
}
first, errFirst := cache.Get(cacheKey{"auth-a", "p1"}, build)
if errFirst != nil {
t.Fatalf("Get() error = %v", errFirst)
}
second, errSecond := cache.Get(cacheKey{"auth-a", "p1"}, build)
if errSecond != nil {
t.Fatalf("Get() second error = %v", errSecond)
}
if first == nil || first != second {
t.Fatalf("expected one cached transport, got %p and %p", first, second)
}
if builds != 1 {
t.Fatalf("build called %d times, want 1", builds)
}
otherProxy, _ := cache.Get(cacheKey{"auth-a", "p2"}, build)
if otherProxy == first {
t.Fatal("distinct proxies must not share a transport")
}
otherScope, _ := cache.Get(cacheKey{"auth-b", "p1"}, build)
if otherScope == first {
t.Fatal("distinct credential scopes must not share a transport")
}
if got := cache.Len(); got != 3 {
t.Fatalf("cache Len() = %d, want 3", got)
}
}
// TestTransportCacheBoundsEntries is the regression test for unbounded pool growth:
// every cached transport owns a connection pool, so churning keys must evict.
func TestTransportCacheBoundsEntries(t *testing.T) {
const capacity = 4
cache := NewTransportCache[cacheKey](capacity)
for i := 0; i < 100; i++ {
key := cacheKey{"auth", string(rune('a' + i%97))}
if _, err := cache.Get(key, func() (*http.Transport, error) { return &http.Transport{}, nil }); err != nil {
t.Fatalf("Get() error = %v", err)
}
if got := cache.Len(); got > capacity {
t.Fatalf("cache grew to %d entries, want at most %d", got, capacity)
}
}
}
// TestTransportCacheEvictsLeastRecentlyUsed proves recency is honoured, so a hot
// credential is not evicted by a burst of one-off keys.
func TestTransportCacheEvictsLeastRecentlyUsed(t *testing.T) {
cache := NewTransportCache[cacheKey](2)
build := func() (*http.Transport, error) { return &http.Transport{}, nil }
hot, _ := cache.Get(cacheKey{"hot", ""}, build)
cache.Get(cacheKey{"cold", ""}, build)
// Touch hot so cold becomes the least recently used entry.
if again, _ := cache.Get(cacheKey{"hot", ""}, build); again != hot {
t.Fatal("expected the hot entry to still be cached")
}
cache.Get(cacheKey{"new", ""}, build)
if again, _ := cache.Get(cacheKey{"hot", ""}, build); again != hot {
t.Fatal("the most recently used entry must survive eviction")
}
}
// TestTransportCacheDoesNotCacheBuildFailures ensures a transient failure neither
// occupies a cache slot nor becomes permanent.
func TestTransportCacheDoesNotCacheBuildFailures(t *testing.T) {
cache := NewTransportCache[cacheKey](4)
key := cacheKey{"auth", "broken"}
if _, err := cache.Get(key, func() (*http.Transport, error) { return nil, errors.New("boom") }); err == nil {
t.Fatal("expected the build error to be propagated")
}
if got := cache.Len(); got != 0 {
t.Fatalf("a failed build must not occupy a cache slot, Len() = %d", got)
}
// A build returning (nil, nil) must be reported rather than cached as usable.
if _, err := cache.Get(key, func() (*http.Transport, error) { return nil, nil }); err == nil {
t.Fatal("expected an error when build returns no transport")
}
transport, err := cache.Get(key, func() (*http.Transport, error) { return &http.Transport{}, nil })
if err != nil || transport == nil {
t.Fatalf("retry after failure must succeed, got (%p, %v)", transport, err)
}
}
func TestTransportCacheConcurrentCallersShareOneInstance(t *testing.T) {
cache := NewTransportCache[cacheKey](8)
key := cacheKey{"auth-concurrent", "socks5://127.0.0.1:1080"}
const callers = 32
results := make([]*http.Transport, callers)
var wg sync.WaitGroup
wg.Add(callers)
for i := 0; i < callers; i++ {
go func(index int) {
defer wg.Done()
results[index], _ = cache.Get(key, func() (*http.Transport, error) { return &http.Transport{}, nil })
}(i)
}
wg.Wait()
for i := 1; i < callers; i++ {
if results[i] != results[0] {
t.Fatalf("caller %d observed a different transport (%p vs %p)", i, results[i], results[0])
}
}
}
func TestTransportCachePurgeAndNilSafety(t *testing.T) {
cache := NewTransportCache[cacheKey](4)
build := func() (*http.Transport, error) { return &http.Transport{}, nil }
cache.Get(cacheKey{"a", ""}, build)
cache.Get(cacheKey{"b", ""}, build)
if got := cache.Len(); got != 2 {
t.Fatalf("Len() = %d, want 2", got)
}
cache.Purge()
if got := cache.Len(); got != 0 {
t.Fatalf("Len() after Purge() = %d, want 0", got)
}
// The cache stays usable after a purge.
if transport, err := cache.Get(cacheKey{"a", ""}, build); err != nil || transport == nil {
t.Fatalf("Get() after Purge() = (%p, %v)", transport, err)
}
var nilCache *TransportCache[cacheKey]
if _, err := nilCache.Get(cacheKey{}, build); err == nil {
t.Fatal("expected an error from a nil cache")
}
if got := nilCache.Len(); got != 0 {
t.Fatalf("nil cache Len() = %d, want 0", got)
}
nilCache.Purge() // must not panic
if _, err := cache.Get(cacheKey{"nil-build", ""}, nil); err == nil {
t.Fatal("expected an error for a nil build function")
}
}
func TestNewTransportCacheDefaultsCapacity(t *testing.T) {
for _, capacity := range []int{0, -1} {
cache := NewTransportCache[cacheKey](capacity)
if cache.capacity != DefaultTransportCacheCapacity {
t.Fatalf("NewTransportCache(%d).capacity = %d, want %d", capacity, cache.capacity, DefaultTransportCacheCapacity)
}
}
}
func TestTransportCacheCloseKeyAndCloseMatching(t *testing.T) {
cache := NewTransportCache[cacheKey](8)
build := func() (*http.Transport, error) { return &http.Transport{}, nil }
cache.Get(cacheKey{"auth-1", "p1"}, build)
cache.Get(cacheKey{"auth-1", "p2"}, build)
cache.Get(cacheKey{"auth-2", "p1"}, build)
if got := cache.Len(); got != 3 {
t.Fatalf("Len() = %d, want 3", got)
}
// Close non-existent key
if closed := cache.CloseKey(cacheKey{"auth-nonexistent", ""}); closed {
t.Fatal("expected CloseKey on missing entry to return false")
}
// Close specific key
if closed := cache.CloseKey(cacheKey{"auth-2", "p1"}); !closed {
t.Fatal("expected CloseKey on existing entry to return true")
}
if got := cache.Len(); got != 2 {
t.Fatalf("Len() after CloseKey = %d, want 2", got)
}
// Close matching all auth-1
closedCount := cache.CloseMatching(func(key cacheKey) bool {
return key.scope == "auth-1"
})
if closedCount != 2 {
t.Fatalf("CloseMatching closed %d entries, want 2", closedCount)
}
if got := cache.Len(); got != 0 {
t.Fatalf("Len() after CloseMatching = %d, want 0", got)
}
// Nil cache safety
var nilCache *TransportCache[cacheKey]
if nilCache.CloseKey(cacheKey{}) {
t.Fatal("expected CloseKey on nil cache to return false")
}
if nilCache.CloseMatching(func(cacheKey) bool { return true }) != 0 {
t.Fatal("expected CloseMatching on nil cache to return 0")
}
}
func TestTransportCacheEvictionClosesIdleConnections(t *testing.T) {
cache := NewTransportCache[cacheKey](2)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
build := func() (*http.Transport, error) {
return &http.Transport{}, nil
}
// Fill 2 entries
t1, err1 := cache.Get(cacheKey{"auth-1", ""}, build)
if err1 != nil {
t.Fatal(err1)
}
_, err2 := cache.Get(cacheKey{"auth-2", ""}, build)
if err2 != nil {
t.Fatal(err2)
}
// Make request with t1 to establish an idle connection
c1 := &http.Client{Transport: t1}
resp, errReq := c1.Get(srv.URL)
if errReq != nil {
t.Fatal(errReq)
}
_ = resp.Body.Close()
// Push 3rd entry: t1 should be evicted and its idle connections closed
_, err3 := cache.Get(cacheKey{"auth-3", ""}, build)
if err3 != nil {
t.Fatal(err3)
}
if cache.Contains(cacheKey{"auth-1", ""}) {
t.Fatal("expected auth-1 to be evicted from cache")
}
if !cache.Contains(cacheKey{"auth-2", ""}) || !cache.Contains(cacheKey{"auth-3", ""}) {
t.Fatal("expected auth-2 and auth-3 to remain in cache")
}
}