proxy-pool/internal/controller/bootstrap/bootstrap_test.go
2026-07-30 11:38:33 +08:00

207 lines
6.2 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{},
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 {
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}
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
`