211 lines
6.3 KiB
Go
211 lines
6.3 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"proxy-pool/internal/config"
|
|
controllerRuntime "proxy-pool/internal/controller/runtime"
|
|
"proxy-pool/internal/domain/activitypool"
|
|
"proxy-pool/internal/domain/adminstate"
|
|
extractionDomain "proxy-pool/internal/domain/extraction"
|
|
)
|
|
|
|
func TestRunLoadsOneSnapshotCommitsItAndClosesInfrastructure(t *testing.T) {
|
|
t.Parallel()
|
|
resolver := &memoryResolver{files: map[string][]byte{"controller.yaml": []byte(bootstrapTestConfig)}}
|
|
state := adminstate.NewMemoryStore()
|
|
activity := &stubActivityStore{}
|
|
closeErr := errors.New("close failed")
|
|
infrastructure := &stubInfrastructure{ports: ports{
|
|
state: state, activity: activity, readiness: readyStub{}, metricsReadiness: readyStub{},
|
|
close: func() error { return closeErr },
|
|
}}
|
|
runErr := errors.New("runtime failed")
|
|
factory := &recordingRuntimeFactory{runner: runnerStub{err: runErr}}
|
|
now := time.Date(2026, 7, 30, 11, 0, 0, 0, time.UTC)
|
|
|
|
err := run(context.Background(), Options{
|
|
ConfigPath: "controller.yaml", Resolver: resolver, Now: func() time.Time { return now },
|
|
}, infrastructure, factory)
|
|
if !errors.Is(err, runErr) || !errors.Is(err, closeErr) {
|
|
t.Fatalf("run() error = %v, want runtime and close errors", err)
|
|
}
|
|
if resolver.reads != 1 {
|
|
t.Fatalf("configuration reads = %d, want 1", resolver.reads)
|
|
}
|
|
if infrastructure.opens != 1 || infrastructure.configuration == nil {
|
|
t.Fatalf("infrastructure opens = %d, config = %p", infrastructure.opens, infrastructure.configuration)
|
|
}
|
|
snapshot, snapshotErr := state.Snapshot(context.Background())
|
|
if snapshotErr != nil || snapshot.Config == nil || snapshot.Config.Source != "controller.yaml" || snapshot.Revision != 1 {
|
|
t.Fatalf("management snapshot = %+v, %v", snapshot, snapshotErr)
|
|
}
|
|
if factory.configuration == nil || factory.dependencies.Extractor == nil ||
|
|
factory.dependencies.Readiness == nil || factory.dependencies.AdminService == nil ||
|
|
factory.dependencies.MetricsHandler == nil {
|
|
t.Fatalf("runtime assembly = config:%p dependencies:%+v", factory.configuration, factory.dependencies)
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsInvalidOptionsBeforeIO(t *testing.T) {
|
|
t.Parallel()
|
|
valid := Options{ConfigPath: "controller.yaml", Resolver: &memoryResolver{}, Now: time.Now}
|
|
tests := []struct {
|
|
name string
|
|
ctx context.Context
|
|
options Options
|
|
}{
|
|
{name: "nil context", options: valid},
|
|
{name: "missing path", ctx: context.Background(), options: Options{Resolver: valid.Resolver, Now: time.Now}},
|
|
{name: "unclean path", ctx: context.Background(), options: Options{ConfigPath: " controller.yaml", Resolver: valid.Resolver, Now: time.Now}},
|
|
{name: "missing resolver", ctx: context.Background(), options: Options{ConfigPath: "controller.yaml", Now: time.Now}},
|
|
}
|
|
for _, test := range tests {
|
|
test := test
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if err := run(test.ctx, test.options, &stubInfrastructure{}, &recordingRuntimeFactory{}); !errors.Is(err, ErrInvalidOptions) {
|
|
t.Fatalf("run() error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type memoryResolver struct {
|
|
files map[string][]byte
|
|
reads int
|
|
}
|
|
|
|
func (*memoryResolver) LookupEnv(string) (string, bool) { return "", false }
|
|
|
|
func (resolver *memoryResolver) ReadFile(path string) ([]byte, error) {
|
|
resolver.reads++
|
|
content, ok := resolver.files[path]
|
|
if !ok {
|
|
return nil, errors.New("file missing")
|
|
}
|
|
return append([]byte(nil), content...), nil
|
|
}
|
|
|
|
type stubInfrastructure struct {
|
|
ports ports
|
|
err error
|
|
opens int
|
|
configuration *config.Config
|
|
}
|
|
|
|
func (infrastructure *stubInfrastructure) Open(
|
|
_ context.Context,
|
|
configuration *config.Config,
|
|
) (ports, error) {
|
|
infrastructure.opens++
|
|
infrastructure.configuration = configuration
|
|
return infrastructure.ports, infrastructure.err
|
|
}
|
|
|
|
type recordingRuntimeFactory struct {
|
|
configuration *config.Config
|
|
dependencies controllerRuntime.Dependencies
|
|
runner controllerRunner
|
|
err error
|
|
}
|
|
|
|
func (factory *recordingRuntimeFactory) New(
|
|
configuration *config.Config,
|
|
dependencies controllerRuntime.Dependencies,
|
|
options controllerRuntime.Options,
|
|
) (controllerRunner, error) {
|
|
factory.configuration = configuration
|
|
factory.dependencies = dependencies
|
|
return factory.runner, factory.err
|
|
}
|
|
|
|
type runnerStub struct{ err error }
|
|
|
|
func (runner runnerStub) Run(context.Context) error { return runner.err }
|
|
|
|
type readyStub struct{}
|
|
|
|
func (readyStub) Ready(context.Context) error { return nil }
|
|
|
|
type stubActivityStore struct{}
|
|
|
|
func (*stubActivityStore) Extract(_ context.Context, command extractionDomain.Command) (extractionDomain.Result, error) {
|
|
return extractionDomain.Result{Requested: command.Requested}, nil
|
|
}
|
|
|
|
func (*stubActivityStore) ReadStateInventory(
|
|
_ context.Context,
|
|
upstreamIDs []string,
|
|
_ time.Time,
|
|
) ([]activitypool.StateInventory, error) {
|
|
result := make([]activitypool.StateInventory, len(upstreamIDs))
|
|
for index, upstreamID := range upstreamIDs {
|
|
result[index].UpstreamID = upstreamID
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
const bootstrapTestConfig = `
|
|
version: 1
|
|
security:
|
|
requireProtectionOnPublicListen: true
|
|
gateway:
|
|
enabled: false
|
|
distribution:
|
|
enabled: true
|
|
listen: 127.0.0.1:0
|
|
auth: {mode: none}
|
|
extraction:
|
|
fulfillment: partial
|
|
maxCountPerRequest: 20
|
|
minRemainingTTL: 5s
|
|
maxHealthCheckAge: 15s
|
|
reserveForGateway: 5
|
|
idempotencyTTL: 5m
|
|
admin:
|
|
enabled: true
|
|
listen: 127.0.0.1:0
|
|
auth: {mode: none}
|
|
metrics:
|
|
enabled: true
|
|
listen: 127.0.0.1:0
|
|
storage:
|
|
postgresURL: postgres://fixture
|
|
redisURL: redis://fixture
|
|
routing:
|
|
- name: extract
|
|
enabled: true
|
|
purpose: extract
|
|
upstreams: [provider-a, provider-b]
|
|
strategy: {type: sequential, switchAfterEmptyFetch: 5, endBehavior: stayLast}
|
|
onUnavailable: {action: reject}
|
|
upstreams:
|
|
provider-a: &upstream
|
|
enabled: true
|
|
exposure: [extract]
|
|
provider: {billingMode: fetch, protocols: [http]}
|
|
api:
|
|
url: https://provider.invalid/proxies
|
|
method: GET
|
|
template: '{{.}}'
|
|
auth: {type: none}
|
|
proxyAuth: {type: response}
|
|
pool: {maxSize: 100}
|
|
capacity: {maxConcurrencyPerProxy: 10}
|
|
lifecycle: {ttl: 2m, allocationSafetyMargin: 10s}
|
|
fetch: {requestInterval: 1s, timeout: 3s, maxAttempts: 3, maxInFlight: 1, maxTotal: 1000}
|
|
check:
|
|
interval: 30s
|
|
jitter: 20
|
|
maxInFlight: 100
|
|
timeout: 2s
|
|
maxAttempts: 2
|
|
maxConsecutiveFailures: 3
|
|
urls: [https://example.invalid/health]
|
|
provider-b: *upstream
|
|
`
|