proxy-pool/internal/controller/provider/reconciler_test.go

841 lines
25 KiB
Go

package provider
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
proxyDomain "github.com/proxy-pool/proxy-pool/internal/domain/proxy"
"github.com/proxy-pool/proxy-pool/internal/domain/upstream"
)
func TestReconcilerCoalescesConcurrentNotifications(t *testing.T) {
var calls atomic.Int64
result := make(chan Result, 1)
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 1,
MaxInFlight: 1,
}, Ports{
Adapter: adapterFunc(func(context.Context) (FetchResponse, error) {
calls.Add(1)
return FetchResponse{Body: []byte("fixture")}, nil
}),
Parser: parserFunc(func(context.Context, []byte) ([]proxyDomain.Proxy, error) {
return []proxyDomain.Proxy{{ID: "proxy-1"}}, nil
}),
Candidates: candidateSinkFunc(func(context.Context, string, []proxyDomain.Proxy) (int, error) {
return 1, nil
}),
Results: resultRecorderFunc(func(got Result) { result <- got }),
Capacity: unlimitedFetchCapacity{},
})
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
var wg sync.WaitGroup
for range 100 {
wg.Add(1)
go func() {
defer wg.Done()
reconciler.Notify()
}()
}
wg.Wait()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
select {
case got := <-result:
if got.Class != upstream.FetchValid {
t.Fatalf("result class = %q, want %q", got.Class, upstream.FetchValid)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for reconcile result")
}
cancel()
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("ProviderAdapter.Fetch() calls = %d, want 1", got)
}
}
func TestReconcilerEnforcesRequestInterval(t *testing.T) {
clock := newFakeClock(time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC))
sleeper := &fakeSleeper{clock: clock}
calledAt := make(chan time.Time, 2)
results := make(chan Result, 2)
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
RequestInterval: 250 * time.Millisecond,
Timeout: time.Second,
MaxAttempts: 1,
MaxInFlight: 1,
}, successfulPorts(func() { calledAt <- clock.Now() }, results), Runtime{
Clock: clock,
Sleeper: sleeper,
})
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
<-results
reconciler.Notify()
<-results
cancel()
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
first, second := <-calledAt, <-calledAt
if got := second.Sub(first); got != 250*time.Millisecond {
t.Fatalf("Provider calls separated by %s, want 250ms", got)
}
if got := sleeper.Durations(); len(got) != 1 || got[0] != 250*time.Millisecond {
t.Fatalf("Sleep durations = %v, want [250ms]", got)
}
}
func TestReconcilerRetriesErrorsWithExponentialBackoffAndJitter(t *testing.T) {
clock := newFakeClock(time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC))
sleeper := &fakeSleeper{clock: clock}
results := make(chan Result, 3)
var calls atomic.Int64
ports := successfulPorts(func() {}, results)
ports.Adapter = adapterFunc(func(context.Context) (FetchResponse, error) {
if calls.Add(1) < 3 {
return FetchResponse{}, errors.New("provider unavailable")
}
return FetchResponse{Body: []byte("fixture")}, nil
})
ports.Candidates = candidateSinkFunc(func(context.Context, string, []proxyDomain.Proxy) (int, error) {
return 0, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 3,
MaxInFlight: 1,
Retry: RetryConfig{
Initial: 100 * time.Millisecond,
Max: time.Second,
Jitter: 20,
},
}, ports, Runtime{Clock: clock, Sleeper: sleeper, Random: fixedRandom(0.75)})
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
wantClasses := []upstream.FetchClass{
upstream.FetchError,
upstream.FetchError,
upstream.FetchDuplicateOnly,
}
for attempt, want := range wantClasses {
select {
case got := <-results:
if got.Class != want || got.Attempt != attempt+1 {
t.Fatalf("result %d = {class:%q attempt:%d}, want {class:%q attempt:%d}", attempt, got.Class, got.Attempt, want, attempt+1)
}
case <-time.After(time.Second):
t.Fatalf("timed out waiting for result %d", attempt)
}
}
cancel()
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
if got := sleeper.Durations(); len(got) != 2 || got[0] != 110*time.Millisecond || got[1] != 220*time.Millisecond {
t.Fatalf("Sleep durations = %v, want [110ms 220ms]", got)
}
}
func TestReconcilerClassifiesFetchResults(t *testing.T) {
tests := []struct {
name string
callErr error
parseErr error
candidates int
newCount int
want upstream.FetchClass
}{
{name: "valid", candidates: 2, newCount: 1, want: upstream.FetchValid},
{name: "empty", want: upstream.FetchEmpty},
{name: "duplicate only", candidates: 2, want: upstream.FetchDuplicateOnly},
{name: "provider error", callErr: errors.New("HTTP 500"), want: upstream.FetchError},
{name: "parser error", parseErr: errors.New("invalid template output"), want: upstream.FetchError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := make(chan Result, 1)
ports := Ports{
Adapter: adapterFunc(func(context.Context) (FetchResponse, error) {
return FetchResponse{Body: []byte("fixture")}, tt.callErr
}),
Parser: parserFunc(func(context.Context, []byte) ([]proxyDomain.Proxy, error) {
return make([]proxyDomain.Proxy, tt.candidates), tt.parseErr
}),
Candidates: candidateSinkFunc(func(context.Context, string, []proxyDomain.Proxy) (int, error) {
return tt.newCount, nil
}),
Results: resultRecorderFunc(func(got Result) { results <- got }),
Capacity: unlimitedFetchCapacity{},
}
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 1,
MaxInFlight: 1,
}, ports)
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
got := runSingleReconcile(t, reconciler, results)
if got.Class != tt.want {
t.Fatalf("result class = %q, want %q", got.Class, tt.want)
}
})
}
}
func TestReconcilerHonorsRetryAfterBeforeBackoff(t *testing.T) {
clock := newFakeClock(time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC))
sleeper := &fakeSleeper{clock: clock}
calledAt := make(chan time.Time, 2)
results := make(chan Result, 2)
var calls atomic.Int64
ports := successfulPorts(func() { calledAt <- clock.Now() }, results)
ports.Adapter = adapterFunc(func(context.Context) (FetchResponse, error) {
calledAt <- clock.Now()
if calls.Add(1) == 1 {
return FetchResponse{RetryAfter: 700 * time.Millisecond}, errors.New("rate limited")
}
return FetchResponse{Body: []byte("fixture")}, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
RequestInterval: 100 * time.Millisecond,
Timeout: time.Second,
MaxAttempts: 2,
MaxInFlight: 1,
Retry: RetryConfig{Initial: 50 * time.Millisecond, Max: time.Second, Jitter: 20},
}, ports, Runtime{Clock: clock, Sleeper: sleeper, Random: fixedRandom(1)})
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
<-results
<-results
cancel()
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
first, second := <-calledAt, <-calledAt
if got := second.Sub(first); got != 700*time.Millisecond {
t.Fatalf("Provider calls separated by %s, want Retry-After 700ms", got)
}
}
func TestReconcilerCapsRetryAfter(t *testing.T) {
clock := newFakeClock(time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC))
sleeper := &fakeSleeper{clock: clock}
results := make(chan Result, 2)
var calls atomic.Int64
ports := successfulPorts(func() {}, results)
ports.Adapter = adapterFunc(func(context.Context) (FetchResponse, error) {
if calls.Add(1) == 1 {
return FetchResponse{RetryAfter: time.Minute}, errors.New("rate limited")
}
return FetchResponse{Body: []byte("fixture")}, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 2,
MaxInFlight: 1,
Retry: RetryConfig{Initial: 50 * time.Millisecond, Max: 2 * time.Second},
}, ports, Runtime{Clock: clock, Sleeper: sleeper})
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
<-results
<-results
cancel()
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
if got := sleeper.Durations(); len(got) != 1 || got[0] != 2*time.Second {
t.Fatalf("Sleep durations = %v, want capped Retry-After [2s]", got)
}
}
func TestReconcilerAppliesAttemptTimeoutToEveryPort(t *testing.T) {
results := make(chan Result, 1)
ports := successfulPorts(func() {}, results)
var stages atomic.Int64
assertDeadline := func(ctx context.Context, stage string) {
deadline, ok := ctx.Deadline()
if !ok {
t.Errorf("%s context has no deadline", stage)
return
}
remaining := time.Until(deadline)
if remaining <= 0 || remaining > 250*time.Millisecond {
t.Errorf("%s deadline remaining = %s, want (0, 250ms]", stage, remaining)
}
stages.Add(1)
}
ports.Adapter = adapterFunc(func(ctx context.Context) (FetchResponse, error) {
assertDeadline(ctx, "ProviderAdapter.Fetch")
return FetchResponse{Body: []byte("fixture")}, nil
})
ports.Parser = parserFunc(func(ctx context.Context, _ []byte) ([]proxyDomain.Proxy, error) {
assertDeadline(ctx, "Parser.Parse")
return []proxyDomain.Proxy{{ID: "proxy-1"}}, nil
})
ports.Candidates = candidateSinkFunc(func(ctx context.Context, _ string, _ []proxyDomain.Proxy) (int, error) {
assertDeadline(ctx, "CandidateSink.Add")
return 1, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: 250 * time.Millisecond,
MaxAttempts: 1,
MaxInFlight: 1,
}, ports)
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
if got := runSingleReconcile(t, reconciler, results); got.Class != upstream.FetchValid {
t.Fatalf("result class = %q, want %q", got.Class, upstream.FetchValid)
}
if got := stages.Load(); got != 3 {
t.Fatalf("ports observing timeout = %d, want 3", got)
}
}
func TestReconcilerDropsNotificationFanoutWhileFetchIsInFlight(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
results := make(chan Result, 2)
var calls atomic.Int64
ports := successfulPorts(func() {}, results)
ports.Adapter = adapterFunc(func(context.Context) (FetchResponse, error) {
calls.Add(1)
close(started)
<-release
return FetchResponse{Body: []byte("fixture")}, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 1,
MaxInFlight: 1,
}, ports)
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
<-started
var wg sync.WaitGroup
for range 100 {
wg.Add(1)
go func() {
defer wg.Done()
reconciler.Notify()
}()
}
wg.Wait()
cancel()
close(release)
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("ProviderAdapter.Fetch() calls = %d, want 1", got)
}
}
func TestReconcilerEnforcesMaxInFlightAcrossRunConsumers(t *testing.T) {
started := make(chan struct{}, 2)
release := make(chan struct{}, 2)
results := make(chan Result, 2)
var active atomic.Int64
var maximum atomic.Int64
ports := successfulPorts(func() {}, results)
ports.Adapter = adapterFunc(func(context.Context) (FetchResponse, error) {
current := active.Add(1)
for {
observed := maximum.Load()
if current <= observed || maximum.CompareAndSwap(observed, current) {
break
}
}
started <- struct{}{}
<-release
active.Add(-1)
return FetchResponse{Body: []byte("fixture")}, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 1,
MaxInFlight: 1,
}, ports)
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 2)
go func() { done <- reconciler.Run(ctx) }()
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
<-started
reconciler.Notify()
release <- struct{}{}
<-started
release <- struct{}{}
<-results
<-results
cancel()
if err := <-done; err != nil {
t.Fatalf("first Run(): %v", err)
}
if err := <-done; err != nil {
t.Fatalf("second Run(): %v", err)
}
if got := maximum.Load(); got != 1 {
t.Fatalf("maximum ProviderAdapter.Fetch() in flight = %d, want 1", got)
}
}
func TestReconcilerUsesConfiguredMaxInFlight(t *testing.T) {
started := make(chan struct{}, 2)
release := make(chan struct{}, 2)
results := make(chan Result, 2)
ports := successfulPorts(func() {}, results)
ports.Adapter = adapterFunc(func(context.Context) (FetchResponse, error) {
started <- struct{}{}
<-release
return FetchResponse{Body: []byte("fixture")}, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 1,
MaxInFlight: 2,
}, ports)
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
<-started
reconciler.Notify()
select {
case <-started:
case <-time.After(time.Second):
release <- struct{}{}
cancel()
<-done
t.Fatal("second ProviderAdapter.Fetch did not use available in-flight slot")
}
release <- struct{}{}
release <- struct{}{}
<-results
<-results
cancel()
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
}
func TestReconcilerDoesNotRefetchWhenCandidateSinkFails(t *testing.T) {
clock := newFakeClock(time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC))
sleeper := &fakeSleeper{clock: clock}
results := make(chan Result, 3)
var calls atomic.Int64
ports := successfulPorts(func() { calls.Add(1) }, results)
ports.Candidates = candidateSinkFunc(func(context.Context, string, []proxyDomain.Proxy) (int, error) {
return 0, errors.New("candidate store unavailable")
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 3,
MaxInFlight: 1,
Retry: RetryConfig{Initial: 100 * time.Millisecond, Max: time.Second},
}, ports, Runtime{Clock: clock, Sleeper: sleeper})
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
result := runSingleReconcile(t, reconciler, results)
if result.Class != upstream.FetchError {
t.Fatalf("result class = %q, want %q", result.Class, upstream.FetchError)
}
if got := calls.Load(); got != 1 {
t.Fatalf("ProviderAdapter.Fetch() calls = %d, want 1", got)
}
if got := sleeper.Durations(); len(got) != 0 {
t.Fatalf("Sleep durations = %v, want no retry backoff", got)
}
}
func TestReconcilerDoesNotRetryPermanentAdapterError(t *testing.T) {
results := make(chan Result, 1)
sleeper := &errorSleeper{}
var calls atomic.Int64
ports := successfulPorts(func() {}, results)
ports.Adapter = adapterFunc(func(context.Context) (FetchResponse, error) {
calls.Add(1)
return FetchResponse{}, permanentFetchError("authentication rejected")
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 3,
MaxInFlight: 1,
Retry: RetryConfig{Initial: time.Second, Max: time.Second},
}, ports, Runtime{Sleeper: sleeper})
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
result := runSingleReconcile(t, reconciler, results)
if result.Class != upstream.FetchError {
t.Fatalf("result class = %q, want %q", result.Class, upstream.FetchError)
}
if got := calls.Load(); got != 1 {
t.Fatalf("ProviderAdapter.Fetch() calls = %d, want 1", got)
}
if got := sleeper.calls.Load(); got != 0 {
t.Fatalf("Sleeper.Sleep() calls = %d, want 0", got)
}
}
func TestReconcilerDoesNotCallProviderWithoutFetchCapacity(t *testing.T) {
results := make(chan Result, 1)
var calls atomic.Int64
ports := successfulPorts(func() { calls.Add(1) }, results)
ports.Capacity = fetchCapacityFunc(func(string) (upstream.FetchPermit, bool, error) {
return nil, false, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1,
}, ports)
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
time.Sleep(20 * time.Millisecond)
cancel()
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("ProviderAdapter.Fetch() calls = %d, want 0", got)
}
select {
case result := <-results:
t.Fatalf("unexpected fetch result: %+v", result)
default:
}
}
func TestReconcilerCompletesFetchPermitWithFetchedAndRetainedCounts(t *testing.T) {
results := make(chan Result, 1)
completed := make(chan fetchCompletion, 1)
ports := successfulPorts(func() {}, results)
ports.Parser = parserFunc(func(context.Context, []byte) ([]proxyDomain.Proxy, error) {
return []proxyDomain.Proxy{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}}, nil
})
ports.Candidates = candidateSinkFunc(func(_ context.Context, _ string, candidates []proxyDomain.Proxy) (int, error) {
if len(candidates) != 2 {
t.Errorf("CandidateSink candidates = %d, want permit limit 2", len(candidates))
}
return 1, nil
})
ports.Capacity = fetchCapacityFunc(func(string) (upstream.FetchPermit, bool, error) {
return &recordingFetchPermit{expected: 2, completed: completed}, true, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1,
}, ports)
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
result := runSingleReconcile(t, reconciler, results)
if result.ValidCount != 3 || result.NewCount != 1 {
t.Fatalf("result = %+v, want valid=3 new=1", result)
}
if got := <-completed; got.fetched != 3 || got.retained != 1 {
t.Fatalf("fetch completion = %+v, want fetched=3 retained=1", got)
}
}
func TestReconcilerRetriesParserErrorsAsFetchErrors(t *testing.T) {
clock := newFakeClock(time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC))
sleeper := &fakeSleeper{clock: clock}
results := make(chan Result, 2)
var calls atomic.Int64
var parses atomic.Int64
ports := successfulPorts(func() { calls.Add(1) }, results)
ports.Parser = parserFunc(func(context.Context, []byte) ([]proxyDomain.Proxy, error) {
if parses.Add(1) == 1 {
return nil, errors.New("temporary parser failure")
}
return []proxyDomain.Proxy{{ID: "proxy-1"}}, nil
})
reconciler, err := NewReconciler(Config{
UpstreamID: "provider-a",
Timeout: time.Second,
MaxAttempts: 2,
MaxInFlight: 1,
Retry: RetryConfig{Initial: 100 * time.Millisecond, Max: time.Second},
}, ports, Runtime{Clock: clock, Sleeper: sleeper})
if err != nil {
t.Fatalf("NewReconciler(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
first, second := <-results, <-results
cancel()
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
if first.Class != upstream.FetchError || second.Class != upstream.FetchValid {
t.Fatalf("result classes = [%q %q], want [%q %q]", first.Class, second.Class, upstream.FetchError, upstream.FetchValid)
}
if got := calls.Load(); got != 2 {
t.Fatalf("ProviderAdapter.Fetch() calls = %d, want 2", got)
}
if got := sleeper.Durations(); len(got) != 1 || got[0] != 100*time.Millisecond {
t.Fatalf("Sleep durations = %v, want [100ms]", got)
}
}
func TestNewReconcilerRejectsInvalidSchedulingConfiguration(t *testing.T) {
results := make(chan Result, 1)
ports := successfulPorts(func() {}, results)
tests := []struct {
name string
config Config
}{
{name: "missing upstream", config: Config{Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1}},
{name: "negative interval", config: Config{UpstreamID: "a", RequestInterval: -1, Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1}},
{name: "missing timeout", config: Config{UpstreamID: "a", MaxAttempts: 1, MaxInFlight: 1}},
{name: "missing attempts", config: Config{UpstreamID: "a", Timeout: time.Second, MaxInFlight: 1}},
{name: "missing in flight", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1}},
{name: "invalid jitter", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, Retry: RetryConfig{Jitter: 101}}},
{name: "initial exceeds max", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, Retry: RetryConfig{Initial: time.Second, Max: time.Millisecond}}},
{name: "incomplete retry pair", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, Retry: RetryConfig{Max: time.Second}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := NewReconciler(tt.config, ports); err == nil {
t.Fatal("NewReconciler() error = nil, want invalid configuration error")
}
})
}
}
func runSingleReconcile(t *testing.T, reconciler *Reconciler, results <-chan Result) Result {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- reconciler.Run(ctx) }()
reconciler.Notify()
var result Result
select {
case result = <-results:
case <-time.After(time.Second):
t.Fatal("timed out waiting for reconcile result")
}
cancel()
if err := <-done; err != nil {
t.Fatalf("Run(): %v", err)
}
return result
}
func successfulPorts(onFetch func(), results chan<- Result) Ports {
return Ports{
Adapter: adapterFunc(func(context.Context) (FetchResponse, error) {
onFetch()
return FetchResponse{Body: []byte("fixture")}, nil
}),
Parser: parserFunc(func(context.Context, []byte) ([]proxyDomain.Proxy, error) {
return []proxyDomain.Proxy{{ID: "proxy-1"}}, nil
}),
Candidates: candidateSinkFunc(func(context.Context, string, []proxyDomain.Proxy) (int, error) {
return 1, nil
}),
Results: resultRecorderFunc(func(got Result) { results <- got }),
Capacity: unlimitedFetchCapacity{},
}
}
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func newFakeClock(now time.Time) *fakeClock { return &fakeClock{now: now} }
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) Advance(duration time.Duration) {
c.mu.Lock()
c.now = c.now.Add(duration)
c.mu.Unlock()
}
type fakeSleeper struct {
mu sync.Mutex
clock *fakeClock
durations []time.Duration
}
type fixedRandom float64
func (r fixedRandom) Float64() float64 { return float64(r) }
type permanentFetchError string
func (e permanentFetchError) Error() string { return string(e) }
func (permanentFetchError) Retryable() bool { return false }
type errorSleeper struct{ calls atomic.Int64 }
func (s *errorSleeper) Sleep(context.Context, time.Duration) error {
s.calls.Add(1)
return errors.New("unexpected sleep")
}
func (s *fakeSleeper) Sleep(ctx context.Context, duration time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
s.durations = append(s.durations, duration)
s.mu.Unlock()
s.clock.Advance(duration)
return nil
}
func (s *fakeSleeper) Durations() []time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
return append([]time.Duration(nil), s.durations...)
}
type adapterFunc func(context.Context) (FetchResponse, error)
func (f adapterFunc) Fetch(ctx context.Context) (FetchResponse, error) { return f(ctx) }
type parserFunc func(context.Context, []byte) ([]proxyDomain.Proxy, error)
func (f parserFunc) Parse(ctx context.Context, body []byte) ([]proxyDomain.Proxy, error) {
return f(ctx, body)
}
type candidateSinkFunc func(context.Context, string, []proxyDomain.Proxy) (int, error)
func (f candidateSinkFunc) Add(ctx context.Context, upstreamID string, candidates []proxyDomain.Proxy) (int, error) {
return f(ctx, upstreamID, candidates)
}
type fetchCapacityFunc func(string) (upstream.FetchPermit, bool, error)
func (f fetchCapacityFunc) ReserveFetch(upstreamID string) (upstream.FetchPermit, bool, error) {
return f(upstreamID)
}
type unlimitedFetchCapacity struct{}
func (unlimitedFetchCapacity) ReserveFetch(string) (upstream.FetchPermit, bool, error) {
return &recordingFetchPermit{expected: int(^uint(0) >> 1)}, true, nil
}
type fetchCompletion struct {
fetched int
retained int
}
type recordingFetchPermit struct {
expected int
completed chan<- fetchCompletion
}
func (p *recordingFetchPermit) Expected() int { return p.expected }
func (p *recordingFetchPermit) Complete(fetched, retained int) error {
if p.completed != nil {
p.completed <- fetchCompletion{fetched: fetched, retained: retained}
}
return nil
}
func (*recordingFetchPermit) Cancel() error { return nil }
type resultRecorderFunc func(Result)
func (f resultRecorderFunc) Record(result Result) { f(result) }