feat(oonirun): improve tests (#915)

See https://github.com/ooni/probe/issues/2184

While there, rename `runtimex.PanicIfFalse` to `runtimex.Assert` (it was about time...)
This commit is contained in:
Simone Basso
2022-08-31 18:40:27 +02:00
committed by GitHub
parent a8a29cc0dd
commit d0da224a2a
32 changed files with 1837 additions and 112 deletions
+40 -8
View File
@@ -11,11 +11,15 @@ import (
"strings"
"time"
"github.com/ooni/probe-cli/v3/internal/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/humanize"
"github.com/ooni/probe-cli/v3/internal/model"
)
// experimentShuffledInputs counts how many times we shuffled inputs
var experimentShuffledInputs = &atomicx.Int64{}
// Experiment describes an experiment to run. You MUST fill all the fields that
// are marked as MANDATORY, otherwise Experiment.Run will cause panics.
type Experiment struct {
@@ -52,6 +56,22 @@ type Experiment struct {
// Session is the MANDATORY session.
Session Session
// newExperimentBuilderFn is OPTIONAL and used for testing.
newExperimentBuilderFn func(experimentName string) (model.ExperimentBuilder, error)
// newInputLoaderFn is OPTIONAL and used for testing.
newInputLoaderFn func(inputPolicy model.InputPolicy) inputLoader
// newSubmitterFn is OPTIONAL and used for testing.
newSubmitterFn func(ctx context.Context) (engine.Submitter, error)
// newSaverFn is OPTIONAL and used for testing.
newSaverFn func(experiment model.Experiment) (engine.Saver, error)
// newInputProcessorFn is OPTIONAL and used for testing.
newInputProcessorFn func(experiment model.Experiment, inputList []model.OOAPIURLInfo,
saver engine.Saver, submitter engine.Submitter) inputProcessor
}
// Run runs the given experiment.
@@ -76,6 +96,7 @@ func (ed *Experiment) Run(ctx context.Context) error {
rnd.Shuffle(len(inputList), func(i, j int) {
inputList[i], inputList[j] = inputList[j], inputList[i]
})
experimentShuffledInputs.Add(1)
}
// 4. configure experiment's options
@@ -112,14 +133,15 @@ func (ed *Experiment) Run(ctx context.Context) error {
return inputProcessor.Run(ctx)
}
// inputProcessor processes inputs running the given experiment.
type inputProcessor interface {
Run(ctx context.Context) error
}
// inputProcessor is an alias for model.ExperimentInputProcessor
type inputProcessor = model.ExperimentInputProcessor
// newInputProcessor creates a new inputProcessor instance.
func (ed *Experiment) newInputProcessor(experiment model.Experiment,
inputList []model.OOAPIURLInfo, saver engine.Saver, submitter engine.Submitter) inputProcessor {
if ed.newInputProcessorFn != nil {
return ed.newInputProcessorFn(experiment, inputList, saver, submitter)
}
return &engine.InputProcessor{
Annotations: ed.Annotations,
Experiment: &experimentWrapper{
@@ -140,6 +162,9 @@ func (ed *Experiment) newInputProcessor(experiment model.Experiment,
// newSaver creates a new engine.Saver instance.
func (ed *Experiment) newSaver(experiment model.Experiment) (engine.Saver, error) {
if ed.newSaverFn != nil {
return ed.newSaverFn(experiment)
}
return engine.NewSaver(engine.SaverConfig{
Enabled: !ed.NoJSON,
Experiment: experiment,
@@ -150,6 +175,9 @@ func (ed *Experiment) newSaver(experiment model.Experiment) (engine.Saver, error
// newSubmitter creates a new engine.Submitter instance.
func (ed *Experiment) newSubmitter(ctx context.Context) (engine.Submitter, error) {
if ed.newSubmitterFn != nil {
return ed.newSubmitterFn(ctx)
}
return engine.NewSubmitter(ctx, engine.SubmitterConfig{
Enabled: !ed.NoCollector,
Session: ed.Session,
@@ -159,16 +187,20 @@ func (ed *Experiment) newSubmitter(ctx context.Context) (engine.Submitter, error
// newExperimentBuilder creates a new engine.ExperimentBuilder for the given experimentName.
func (ed *Experiment) newExperimentBuilder(experimentName string) (model.ExperimentBuilder, error) {
if ed.newExperimentBuilderFn != nil {
return ed.newExperimentBuilderFn(experimentName)
}
return ed.Session.NewExperimentBuilder(ed.Name)
}
// inputLoader loads inputs from local or remote sources.
type inputLoader interface {
Load(ctx context.Context) ([]model.OOAPIURLInfo, error)
}
// inputLoader is an alias for model.ExperimentInputLoader
type inputLoader = model.ExperimentInputLoader
// newInputLoader creates a new inputLoader.
func (ed *Experiment) newInputLoader(inputPolicy model.InputPolicy) inputLoader {
if ed.newInputLoaderFn != nil {
return ed.newInputLoaderFn(inputPolicy)
}
return &engine.InputLoader{
CheckInConfig: &model.OOAPICheckInConfig{
RunType: model.RunTypeManual,
+336 -30
View File
@@ -2,42 +2,24 @@ package oonirun
import (
"context"
"os"
"errors"
"reflect"
"sort"
"testing"
"time"
"github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/kvstore"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/version"
"github.com/ooni/probe-cli/v3/internal/model/mocks"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
// TODO(bassosimone): it would be cool to write unit tests. However, to do that
// we need to ~redesign the engine package for unit-testability.
func newSession(ctx context.Context, t *testing.T) *engine.Session {
config := engine.SessionConfig{
AvailableProbeServices: []model.OOAPIService{},
KVStore: &kvstore.Memory{},
Logger: model.DiscardLogger,
ProxyURL: nil,
SoftwareName: "miniooni",
SoftwareVersion: version.Version,
TempDir: os.TempDir(),
TorArgs: []string{},
TorBinary: "",
TunnelDir: "",
}
sess, err := engine.NewSession(ctx, config)
if err != nil {
t.Fatal(err)
}
return sess
}
func TestExperimentRunWithExample(t *testing.T) {
func TestExperimentRunWithFailureToSubmitAndShuffle(t *testing.T) {
shuffledInputsPrev := experimentShuffledInputs.Load()
var calledSetOptionsAny int
var failedToSubmit int
var calledKibiBytesReceived int
var calledKibiBytesSent int
ctx := context.Background()
desc := &Experiment{
Annotations: map[string]string{
@@ -46,19 +28,89 @@ func TestExperimentRunWithExample(t *testing.T) {
ExtraOptions: map[string]any{
"SleepTime": int64(10 * time.Millisecond),
},
Inputs: []string{},
Inputs: []string{
"a", "b", "c",
},
InputFilePaths: []string{},
MaxRuntime: 0,
Name: "example",
NoCollector: true,
NoJSON: true,
Random: false,
Random: true, // to test randomness
ReportFile: "",
Session: newSession(ctx, t),
Session: &mocks.Session{
MockNewExperimentBuilder: func(name string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
calledSetOptionsAny++
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockMeasureAsync: func(ctx context.Context, input string) (<-chan *model.Measurement, error) {
out := make(chan *model.Measurement)
go func() {
defer close(out)
ff := &testingx.FakeFiller{}
var meas model.Measurement
ff.Fill(&meas)
out <- &meas
}()
return out, nil
},
MockKibiBytesReceived: func() float64 {
calledKibiBytesReceived++
return 1.453
},
MockKibiBytesSent: func() float64 {
calledKibiBytesSent++
return 1.648
},
}
return exp
},
}
return eb, nil
},
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
newExperimentBuilderFn: nil,
newInputLoaderFn: nil,
newSubmitterFn: func(ctx context.Context) (engine.Submitter, error) {
subm := &mocks.Submitter{
MockSubmit: func(ctx context.Context, m *model.Measurement) error {
failedToSubmit++
return errors.New("mocked error")
},
}
return subm, nil
},
newSaverFn: nil,
newInputProcessorFn: nil,
}
if err := desc.Run(ctx); err != nil {
t.Fatal(err)
}
if failedToSubmit < 1 {
t.Fatal("expected to see failure to submit")
}
if experimentShuffledInputs.Load() != shuffledInputsPrev+1 {
t.Fatal("did not shuffle inputs")
}
if calledSetOptionsAny < 1 {
t.Fatal("should have called SetOptionsAny")
}
if calledKibiBytesReceived < 1 {
t.Fatal("did not call KibiBytesReceived")
}
if calledKibiBytesSent < 1 {
t.Fatal("did not call KibiBytesSent")
}
}
func Test_experimentOptionsToStringList(t *testing.T) {
@@ -102,3 +154,257 @@ func Test_experimentOptionsToStringList(t *testing.T) {
})
}
}
func TestExperimentRun(t *testing.T) {
errMocked := errors.New("mocked error")
type fields struct {
Annotations map[string]string
ExtraOptions map[string]any
Inputs []string
InputFilePaths []string
MaxRuntime int64
Name string
NoCollector bool
NoJSON bool
Random bool
ReportFile string
Session Session
newExperimentBuilderFn func(experimentName string) (model.ExperimentBuilder, error)
newInputLoaderFn func(inputPolicy model.InputPolicy) inputLoader
newSubmitterFn func(ctx context.Context) (engine.Submitter, error)
newSaverFn func(experiment model.Experiment) (engine.Saver, error)
newInputProcessorFn func(experiment model.Experiment, inputList []model.OOAPIURLInfo, saver engine.Saver, submitter engine.Submitter) inputProcessor
}
type args struct {
ctx context.Context
}
tests := []struct {
name string
fields fields
args args
expectErr error
}{{
name: "cannot construct an experiment builder",
fields: fields{
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
return nil, errMocked
},
},
args: args{},
expectErr: errMocked,
}, {
name: "cannot load input",
fields: fields{
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return nil, errMocked
},
}
},
},
args: args{},
expectErr: errMocked,
}, {
name: "cannot set options",
fields: fields{
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
return errMocked
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return []model.OOAPIURLInfo{}, nil
},
}
},
},
args: args{},
expectErr: errMocked,
}, {
name: "cannot create new submitter",
fields: fields{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockKibiBytesReceived: func() float64 {
return 0
},
MockKibiBytesSent: func() float64 {
return 0
},
}
return exp
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return []model.OOAPIURLInfo{}, nil
},
}
},
newSubmitterFn: func(ctx context.Context) (engine.Submitter, error) {
return nil, errMocked
},
},
args: args{},
expectErr: errMocked,
}, {
name: "cannot create new saver",
fields: fields{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockKibiBytesReceived: func() float64 {
return 0
},
MockKibiBytesSent: func() float64 {
return 0
},
}
return exp
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return []model.OOAPIURLInfo{}, nil
},
}
},
newSubmitterFn: func(ctx context.Context) (engine.Submitter, error) {
return &mocks.Submitter{}, nil
},
newSaverFn: func(experiment model.Experiment) (engine.Saver, error) {
return nil, errMocked
},
},
args: args{},
expectErr: errMocked,
}, {
name: "input processor fails",
fields: fields{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
newExperimentBuilderFn: func(experimentName string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOptional
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockKibiBytesReceived: func() float64 {
return 0
},
MockKibiBytesSent: func() float64 {
return 0
},
}
return exp
},
}
return eb, nil
},
newInputLoaderFn: func(inputPolicy model.InputPolicy) inputLoader {
return &mocks.ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return []model.OOAPIURLInfo{}, nil
},
}
},
newSubmitterFn: func(ctx context.Context) (engine.Submitter, error) {
return &mocks.Submitter{}, nil
},
newSaverFn: func(experiment model.Experiment) (engine.Saver, error) {
return &mocks.Saver{}, nil
},
newInputProcessorFn: func(experiment model.Experiment, inputList []model.OOAPIURLInfo,
saver engine.Saver, submitter engine.Submitter) inputProcessor {
return &mocks.ExperimentInputProcessor{
MockRun: func(ctx context.Context) error {
return errMocked
},
}
},
},
args: args{},
expectErr: errMocked,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ed := &Experiment{
Annotations: tt.fields.Annotations,
ExtraOptions: tt.fields.ExtraOptions,
Inputs: tt.fields.Inputs,
InputFilePaths: tt.fields.InputFilePaths,
MaxRuntime: tt.fields.MaxRuntime,
Name: tt.fields.Name,
NoCollector: tt.fields.NoCollector,
NoJSON: tt.fields.NoJSON,
Random: tt.fields.Random,
ReportFile: tt.fields.ReportFile,
Session: tt.fields.Session,
newExperimentBuilderFn: tt.fields.newExperimentBuilderFn,
newInputLoaderFn: tt.fields.newInputLoaderFn,
newSubmitterFn: tt.fields.newSubmitterFn,
newSaverFn: tt.fields.newSaverFn,
newInputProcessorFn: tt.fields.newInputProcessorFn,
}
err := ed.Run(tt.args.ctx)
if !errors.Is(err, tt.expectErr) {
t.Fatalf("Experiment.Run() error = %v, expectErr %v", err, tt.expectErr)
}
})
}
}
+39 -21
View File
@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
)
@@ -50,7 +51,7 @@ func v1Measure(ctx context.Context, config *LinkConfig, URL string) error {
if pu.Host != "nettest" {
return ErrInvalidV1URLHost
}
if pu.Path != "" {
if pu.Path != "" && pu.Path != "/" {
return ErrInvalidV1URLPath
}
default:
@@ -58,33 +59,50 @@ func v1Measure(ctx context.Context, config *LinkConfig, URL string) error {
}
name := pu.Query().Get("tn")
if name == "" {
return ErrInvalidV1URLQueryArgument
return fmt.Errorf("%w: empty test name", ErrInvalidV1URLQueryArgument)
}
var inputs []string
if ra := pu.Query().Get("ta"); ra != "" {
pa, err := url.QueryUnescape(ra)
if ta := pu.Query().Get("ta"); ta != "" {
inputs, err = v1ParseArguments(ta)
if err != nil {
return err
}
var arguments v1Arguments
if err := json.Unmarshal([]byte(pa), &arguments); err != nil {
return err
}
inputs = arguments.URLs
}
// TODO(bassosimone): reject mv < 1.2.0
if mv := pu.Query().Get("mv"); mv != "1.2.0" {
return fmt.Errorf("%w: unknown minimum version", ErrInvalidV1URLQueryArgument)
}
exp := &Experiment{
Annotations: config.Annotations,
ExtraOptions: nil, // no way to specify with v1 URLs
Inputs: inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: name,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
Annotations: config.Annotations,
ExtraOptions: nil, // no way to specify with v1 URLs
Inputs: inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: name,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
newExperimentBuilderFn: nil,
newInputLoaderFn: nil,
newSubmitterFn: nil,
newSaverFn: nil,
newInputProcessorFn: nil,
}
return exp.Run(ctx)
}
// v1ParseArguments parses the `ta` field of the query string.
func v1ParseArguments(ta string) ([]string, error) {
var inputs []string
pa, err := url.QueryUnescape(ta)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidV1URLQueryArgument, err.Error())
}
var arguments v1Arguments
if err := json.Unmarshal([]byte(pa), &arguments); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidV1URLQueryArgument, err.Error())
}
inputs = arguments.URLs
return inputs, nil
}
+235 -3
View File
@@ -2,13 +2,60 @@ package oonirun
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"github.com/ooni/probe-cli/v3/internal/kvstore"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/model/mocks"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
// TODO(bassosimone): it would be cool to write unit tests. However, to do that
// we need to ~redesign the engine package for unit-testability.
func newMinimalFakeSession() *mocks.Session {
return &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
MockNewExperimentBuilder: func(name string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputNone
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockMeasureAsync: func(ctx context.Context, input string) (<-chan *model.Measurement, error) {
out := make(chan *model.Measurement)
go func() {
defer close(out)
ff := &testingx.FakeFiller{}
var meas model.Measurement
ff.Fill(&meas)
out <- &meas
}()
return out, nil
},
MockKibiBytesReceived: func() float64 {
return 1.1
},
MockKibiBytesSent: func() float64 {
return 0.1
},
}
return exp
},
}
return eb, nil
},
MockDefaultHTTPClient: func() model.HTTPClient {
return http.DefaultClient
},
}
}
func TestOONIRunV1Link(t *testing.T) {
ctx := context.Background()
@@ -23,7 +70,7 @@ func TestOONIRunV1Link(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, "https://run.ooni.io/nettest?tn=example&mv=1.2.0")
if err := r.Run(ctx); err != nil {
@@ -34,3 +81,188 @@ func TestOONIRunV1Link(t *testing.T) {
t.Fatal(err)
}
}
func TestV1MeasureInvalidURL(t *testing.T) {
t.Run("URL does not parse", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "\t"
err := v1Measure(ctx, config, URL)
if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
t.Fatal("unexpected err", err)
}
})
t.Run("with https:// URL and invalid hostname", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "https://run.ooni.nu/nettest"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLHost) {
t.Fatal("unexpected err", err)
}
})
t.Run("with https:// URL and invalid path", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "https://run.ooni.io/antani"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLPath) {
t.Fatal("unexpected err", err)
}
})
t.Run("with ooni:// URL and invalid host", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://antani"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLHost) {
t.Fatal("unexpected err", err)
}
})
t.Run("with ooni:// URL and path", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest/x"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLPath) {
t.Fatal("unexpected err", err)
}
})
t.Run("with invalid URL scheme", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "antani://nettest"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLScheme) {
t.Fatal("unexpected err", err)
}
})
t.Run("with empty test name", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest/"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
})
t.Run("with invalid JSON and explicit / as path", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest/?tn=web_connectivity&ta=123x"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
})
t.Run("with invalid JSON and empty path", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest?tn=web_connectivity&ta=123x"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
})
t.Run("with missing minimum version", func(t *testing.T) {
ctx := context.Background()
config := &LinkConfig{
Session: &mocks.Session{
MockLogger: func() model.Logger {
return model.DiscardLogger
},
},
}
URL := "ooni://nettest?tn=example"
err := v1Measure(ctx, config, URL)
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
})
}
func TestV1ParseArguments(t *testing.T) {
t.Run("with invalid test arguments", func(t *testing.T) {
// "[QueryUnescape] returns an error if any % is not followed by two hexadecimal digits."
out, err := v1ParseArguments("%KK")
if !errors.Is(err, ErrInvalidV1URLQueryArgument) {
t.Fatal("unexpected err", err)
}
if len(out) > 0 {
t.Fatal("expected no output")
}
})
t.Run("with valid arguments", func(t *testing.T) {
out, err := v1ParseArguments("%7B%22urls%22%3A%5B%22https%3A%2F%2Fexample.com%2F%22%5D%7D")
if err != nil {
t.Fatal(err)
}
if len(out) != 1 || out[0] != "https://example.com/" {
t.Fatal("unexpected out", out)
}
})
}
+21 -11
View File
@@ -24,6 +24,10 @@ var (
// v2CountEmptyNettestNames counts the number of cases in which we have been
// given an empty nettest name, which is useful for testing.
v2CountEmptyNettestNames = &atomicx.Int64{}
// v2CountFailedExperiments countes the number of failed experiments
// and is useful when testing this package
v2CountFailedExperiments = &atomicx.Int64{}
)
// v2Descriptor describes a single nettest to run.
@@ -172,20 +176,26 @@ func v2MeasureDescriptor(ctx context.Context, config *LinkConfig, desc *v2Descri
continue
}
exp := &Experiment{
Annotations: config.Annotations,
ExtraOptions: nettest.Options,
Inputs: nettest.Inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: nettest.TestName,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
Annotations: config.Annotations,
ExtraOptions: nettest.Options,
Inputs: nettest.Inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: nettest.TestName,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
newExperimentBuilderFn: nil,
newInputLoaderFn: nil,
newSubmitterFn: nil,
newSaverFn: nil,
newInputProcessorFn: nil,
}
if err := exp.Run(ctx); err != nil {
logger.Warnf("cannot run experiment: %s", err.Error())
v2CountFailedExperiments.Add(1)
continue
}
}
+79 -12
View File
@@ -10,13 +10,11 @@ import (
"time"
"github.com/ooni/probe-cli/v3/internal/kvstore"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/model/mocks"
"github.com/ooni/probe-cli/v3/internal/runtimex"
)
// TODO(bassosimone): it would be cool to write unit tests. However, to do that
// we need to ~redesign the engine package for unit-testability.
func TestOONIRunV2LinkCommonCase(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
descriptor := &v2Descriptor{
@@ -48,7 +46,7 @@ func TestOONIRunV2LinkCommonCase(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
if err := r.Run(ctx); err != nil {
@@ -95,7 +93,7 @@ func TestOONIRunV2LinkCannotUpdateCache(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
err := r.Run(ctx)
@@ -135,7 +133,7 @@ func TestOONIRunV2LinkWithoutAcceptChanges(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
err := r.Run(ctx)
@@ -161,7 +159,7 @@ func TestOONIRunV2LinkNilDescriptor(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
if err := r.Run(ctx); err != nil {
@@ -170,6 +168,7 @@ func TestOONIRunV2LinkNilDescriptor(t *testing.T) {
}
func TestOONIRunV2LinkEmptyTestName(t *testing.T) {
emptyTestNamesPrev := v2CountEmptyNettestNames.Load()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
descriptor := &v2Descriptor{
Name: "",
@@ -200,14 +199,14 @@ func TestOONIRunV2LinkEmptyTestName(t *testing.T) {
NoJSON: true,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
r := NewLinkRunner(config, server.URL)
if err := r.Run(ctx); err != nil {
t.Fatal(err)
}
if v2CountEmptyNettestNames.Load() != 1 {
t.Fatal("expected to see 1 instance of empty nettest names")
if v2CountEmptyNettestNames.Load() != emptyTestNamesPrev+1 {
t.Fatal("expected to see 1 more instance of empty nettest names")
}
}
@@ -220,6 +219,74 @@ func TestV2MeasureDescriptor(t *testing.T) {
t.Fatal("unexpected err", err)
}
})
t.Run("with failing experiment", func(t *testing.T) {
previousFailedExperiments := v2CountFailedExperiments.Load()
expected := errors.New("mocked error")
ctx := context.Background()
sess := newMinimalFakeSession()
sess.MockNewSubmitter = func(ctx context.Context) (model.Submitter, error) {
subm := &mocks.Submitter{
MockSubmit: func(ctx context.Context, m *model.Measurement) error {
panic("should not be called")
},
}
return subm, nil
}
sess.MockNewExperimentBuilder = func(name string) (model.ExperimentBuilder, error) {
eb := &mocks.ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputNone
},
MockSetOptionsAny: func(options map[string]any) error {
return nil
},
MockNewExperiment: func() model.Experiment {
exp := &mocks.Experiment{
MockMeasureAsync: func(ctx context.Context, input string) (<-chan *model.Measurement, error) {
return nil, expected
},
MockKibiBytesReceived: func() float64 {
return 1.1
},
MockKibiBytesSent: func() float64 {
return 0.1
},
}
return exp
},
}
return eb, nil
}
config := &LinkConfig{
AcceptChanges: false,
Annotations: map[string]string{},
KVStore: nil,
MaxRuntime: 0,
NoCollector: false,
NoJSON: false,
Random: false,
ReportFile: "",
Session: sess,
}
descr := &v2Descriptor{
Name: "",
Description: "",
Author: "",
Nettests: []v2Nettest{{
Inputs: []string{},
Options: map[string]any{},
TestName: "example",
}},
}
err := v2MeasureDescriptor(ctx, config, descr)
if err != nil {
t.Fatal(err)
}
if v2CountFailedExperiments.Load() != previousFailedExperiments+1 {
t.Fatal("expected to see a failed experiment")
}
})
}
func TestV2MeasureHTTPS(t *testing.T) {
@@ -239,7 +306,7 @@ func TestV2MeasureHTTPS(t *testing.T) {
NoJSON: false,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
err := v2MeasureHTTPS(ctx, config, "")
if !errors.Is(err, expected) {
@@ -259,7 +326,7 @@ func TestV2MeasureHTTPS(t *testing.T) {
NoJSON: false,
Random: false,
ReportFile: "",
Session: newSession(ctx, t),
Session: newMinimalFakeSession(),
}
err := v2MeasureHTTPS(ctx, config, "https://example.com") // should not use URL
if !errors.Is(err, context.Canceled) {