package health import ( "context" "errors" "testing" "time" healthDomain "proxy-pool/internal/domain/health" proxyDomain "proxy-pool/internal/domain/proxy" ) func TestSchedulerRunnerUsesBoundedDueBatchAndSharedInFlightCount(t *testing.T) { planner, err := NewPlanner(SchedulePolicy{ Interval: time.Minute, MaxInFlight: 3, Timeout: time.Second, MaxAttempts: 1, }) if err != nil { t.Fatalf("NewPlanner(): %v", err) } now := time.Date(2026, 7, 31, 18, 0, 0, 0, time.UTC) source := &dueSourceStub{inFlight: 1, candidates: []Candidate{ {ProxyID: "fetched", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now}, {ProxyID: "available", State: proxyDomain.StateAvailable, Level: healthDomain.LevelBasic, DueAt: now}, }} sink := &taskSinkStub{} runner, err := NewSchedulerRunner(planner, source, sink, SchedulerRunnerOptions{ PollInterval: time.Second, BatchSize: 2, Now: func() time.Time { return now }, }) if err != nil { t.Fatalf("NewSchedulerRunner(): %v", err) } result, err := runner.Tick(context.Background()) if err != nil || result.Planned != 2 || result.Offered != 2 || source.limit != 2 || len(sink.tasks) != 2 || sink.tasks[0].Priority != PriorityFetched { t.Fatalf("Tick() = (%+v, %v); source=%+v sink=%+v", result, err, source, sink.tasks) } } func TestSchedulerRunnerSkipsDueReadAtSharedCapacity(t *testing.T) { planner, err := NewPlanner(SchedulePolicy{ Interval: time.Minute, MaxInFlight: 1, Timeout: time.Second, MaxAttempts: 1, }) if err != nil { t.Fatalf("NewPlanner(): %v", err) } source := &dueSourceStub{inFlight: 1} runner, err := NewSchedulerRunner(planner, source, &taskSinkStub{}, SchedulerRunnerOptions{ PollInterval: time.Second, BatchSize: 1, Now: time.Now, }) if err != nil { t.Fatalf("NewSchedulerRunner(): %v", err) } result, err := runner.Tick(context.Background()) if err != nil || result != (TickResult{}) || source.limit != 0 { t.Fatalf("Tick(at capacity) = (%+v, %v); source=%+v", result, err, source) } } func TestSchedulerRunnerRejectsOversizedSourceAndSinkResponses(t *testing.T) { planner, err := NewPlanner(SchedulePolicy{ Interval: time.Minute, MaxInFlight: 2, Timeout: time.Second, MaxAttempts: 1, }) if err != nil { t.Fatalf("NewPlanner(): %v", err) } now := time.Now() runner, err := NewSchedulerRunner(planner, &dueSourceStub{candidates: []Candidate{ {ProxyID: "one", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now}, {ProxyID: "two", State: proxyDomain.StateFetched, Level: healthDomain.LevelBasic, DueAt: now}, }}, &taskSinkStub{}, SchedulerRunnerOptions{PollInterval: time.Second, BatchSize: 1, Now: func() time.Time { return now }}) if err != nil { t.Fatalf("NewSchedulerRunner(): %v", err) } if _, err := runner.Tick(context.Background()); !errors.Is(err, ErrInvalidDueSource) { t.Fatalf("Tick(oversized source) error = %v, want ErrInvalidDueSource", err) } } type dueSourceStub struct { inFlight int candidates []Candidate limit int } func (source *dueSourceStub) InFlight(context.Context, time.Time) (int, error) { return source.inFlight, nil } func (source *dueSourceStub) DueCandidates(_ context.Context, _ time.Time, limit int) ([]Candidate, error) { source.limit = limit return source.candidates, nil } type taskSinkStub struct { tasks []PlannedTask offer int } func (sink *taskSinkStub) Offer(_ context.Context, tasks []PlannedTask) (int, error) { sink.tasks = append([]PlannedTask(nil), tasks...) if sink.offer != 0 { return sink.offer, nil } return len(tasks), nil }