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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1837 additions and 112 deletions

View File

@ -17,7 +17,7 @@ func acquireUserConsent(miniooniDir string, currentOptions *Options) {
consentFile := path.Join(miniooniDir, "informed") consentFile := path.Join(miniooniDir, "informed")
err := maybeWriteConsentFile(currentOptions.Yes, consentFile) err := maybeWriteConsentFile(currentOptions.Yes, consentFile)
runtimex.PanicOnError(err, "cannot write informed consent file") runtimex.PanicOnError(err, "cannot write informed consent file")
runtimex.PanicIfFalse( runtimex.Assert(
regularFileExists(consentFile), regularFileExists(consentFile),
riskOfRunningOONI, riskOfRunningOONI,
) )

View File

@ -308,7 +308,7 @@ func mainSingleIteration(logger model.Logger, experimentName string, currentOpti
log.Infof("Current time: %s", time.Now().Format("2006-01-02 15:04:05 MST")) log.Infof("Current time: %s", time.Now().Format("2006-01-02 15:04:05 MST"))
homeDir := gethomedir(currentOptions.HomeDir) homeDir := gethomedir(currentOptions.HomeDir)
runtimex.PanicIfFalse(homeDir != "", "home directory is empty") runtimex.Assert(homeDir != "", "home directory is empty")
miniooniDir := path.Join(homeDir, ".miniooni") miniooniDir := path.Join(homeDir, ".miniooni")
err := os.MkdirAll(miniooniDir, 0700) err := os.MkdirAll(miniooniDir, 0700)
runtimex.PanicOnError(err, "cannot create $HOME/.miniooni directory") runtimex.PanicOnError(err, "cannot create $HOME/.miniooni directory")

View File

@ -11,6 +11,8 @@ import (
) )
// Session allows to mock sessions. // Session allows to mock sessions.
//
// Deprecated: use ./internal/model/mocks.Session instead.
type Session struct { type Session struct {
MockableTestHelpers map[string][]model.OOAPIService MockableTestHelpers map[string][]model.OOAPIService
MockableHTTPClient model.HTTPClient MockableHTTPClient model.HTTPClient

View File

@ -6,10 +6,8 @@ import (
"github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/model"
) )
// Saver saves a measurement on some persistent storage. // Saver is an alias for model.Saver.
type Saver interface { type Saver = model.Saver
SaveMeasurement(m *model.Measurement) error
}
// SaverConfig is the configuration for creating a new Saver. // SaverConfig is the configuration for creating a new Saver.
type SaverConfig struct { type SaverConfig struct {

View File

@ -9,12 +9,8 @@ import (
// TODO(bassosimone): maybe keep track of which measurements // TODO(bassosimone): maybe keep track of which measurements
// could not be submitted by a specific submitter? // could not be submitted by a specific submitter?
// Submitter submits a measurement to the OONI collector. // Submitter is an alias for model.Submitter
type Submitter interface { type Submitter = model.Submitter
// Submit submits the measurement and updates its
// report ID field in case of success.
Submit(ctx context.Context, m *model.Measurement) error
}
// SubmitterSession is the Submitter's view of the Session. // SubmitterSession is the Submitter's view of the Session.
type SubmitterSession interface { type SubmitterSession interface {

View File

@ -27,7 +27,7 @@ func NewFailure(err error) *string {
if !errors.As(err, &errWrapper) { if !errors.As(err, &errWrapper) {
err := netxlite.NewTopLevelGenericErrWrapper(err) err := netxlite.NewTopLevelGenericErrWrapper(err)
couldConvert := errors.As(err, &errWrapper) couldConvert := errors.As(err, &errWrapper)
runtimex.PanicIfFalse(couldConvert, "we should have an ErrWrapper here") runtimex.Assert(couldConvert, "we should have an ErrWrapper here")
} }
s := errWrapper.Failure s := errWrapper.Failure
if s == "" { if s == "" {

View File

@ -281,3 +281,25 @@ type ExperimentOptionInfo struct {
// Type contains the type. // Type contains the type.
Type string Type string
} }
// ExperimentInputLoader loads inputs from local or remote sources.
type ExperimentInputLoader interface {
Load(ctx context.Context) ([]OOAPIURLInfo, error)
}
// Submitter submits a measurement to the OONI collector.
type Submitter interface {
// Submit submits the measurement and updates its
// report ID field in case of success.
Submit(ctx context.Context, m *Measurement) error
}
// Saver saves a measurement on some persistent storage.
type Saver interface {
SaveMeasurement(m *Measurement) error
}
// ExperimentInputProcessor processes inputs for an experiment.
type ExperimentInputProcessor interface {
Run(ctx context.Context) error
}

View File

@ -0,0 +1,75 @@
package mocks
import (
"context"
"github.com/ooni/probe-cli/v3/internal/model"
)
// Experiment mocks model.Experiment
type Experiment struct {
MockKibiBytesReceived func() float64
MockKibiBytesSent func() float64
MockName func() string
MockGetSummaryKeys func(m *model.Measurement) (any, error)
MockReportID func() string
MockMeasureAsync func(ctx context.Context, input string) (<-chan *model.Measurement, error)
MockMeasureWithContext func(
ctx context.Context, input string) (measurement *model.Measurement, err error)
MockSaveMeasurement func(measurement *model.Measurement, filePath string) error
MockSubmitAndUpdateMeasurementContext func(
ctx context.Context, measurement *model.Measurement) error
MockOpenReportContext func(ctx context.Context) error
}
func (e *Experiment) KibiBytesReceived() float64 {
return e.MockKibiBytesReceived()
}
func (e *Experiment) KibiBytesSent() float64 {
return e.MockKibiBytesSent()
}
func (e *Experiment) Name() string {
return e.MockName()
}
func (e *Experiment) GetSummaryKeys(m *model.Measurement) (any, error) {
return e.MockGetSummaryKeys(m)
}
func (e *Experiment) ReportID() string {
return e.MockReportID()
}
func (e *Experiment) MeasureAsync(
ctx context.Context, input string) (<-chan *model.Measurement, error) {
return e.MockMeasureAsync(ctx, input)
}
func (e *Experiment) MeasureWithContext(
ctx context.Context, input string) (measurement *model.Measurement, err error) {
return e.MockMeasureWithContext(ctx, input)
}
func (e *Experiment) SaveMeasurement(measurement *model.Measurement, filePath string) error {
return e.MockSaveMeasurement(measurement, filePath)
}
func (e *Experiment) SubmitAndUpdateMeasurementContext(
ctx context.Context, measurement *model.Measurement) error {
return e.MockSubmitAndUpdateMeasurementContext(ctx, measurement)
}
func (e *Experiment) OpenReportContext(ctx context.Context) error {
return e.MockOpenReportContext(ctx)
}

View File

@ -0,0 +1,146 @@
package mocks
import (
"context"
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestExperiment(t *testing.T) {
t.Run("KibiBytesReceived", func(t *testing.T) {
expected := 1.0
e := &Experiment{
MockKibiBytesReceived: func() float64 {
return expected
},
}
if e.KibiBytesReceived() != expected {
t.Fatal("unexpected result")
}
})
t.Run("KibiBytesSent", func(t *testing.T) {
expected := 1.0
e := &Experiment{
MockKibiBytesSent: func() float64 {
return expected
},
}
if e.KibiBytesSent() != expected {
t.Fatal("unexpected result")
}
})
t.Run("Name", func(t *testing.T) {
expected := "antani"
e := &Experiment{
MockName: func() string {
return expected
},
}
if e.Name() != expected {
t.Fatal("unexpected result")
}
})
t.Run("GetSummaryKeys", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockGetSummaryKeys: func(m *model.Measurement) (any, error) {
return nil, expected
},
}
out, err := e.GetSummaryKeys(&model.Measurement{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if out != nil {
t.Fatal("invalid out")
}
})
t.Run("ReportID", func(t *testing.T) {
expect := "xyz"
e := &Experiment{
MockReportID: func() string {
return expect
},
}
if e.ReportID() != expect {
t.Fatal("invalid value")
}
})
t.Run("MeasureAsync", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockMeasureAsync: func(ctx context.Context, input string) (<-chan *model.Measurement, error) {
return nil, expected
},
}
out, err := e.MeasureAsync(context.Background(), "xo")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if out != nil {
t.Fatal("expected nil")
}
})
t.Run("MeasureWithContext", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockMeasureWithContext: func(ctx context.Context, input string) (measurement *model.Measurement, err error) {
return nil, expected
},
}
out, err := e.MeasureWithContext(context.Background(), "xo")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if out != nil {
t.Fatal("expected nil")
}
})
t.Run("SaveMeasurement", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockSaveMeasurement: func(measurement *model.Measurement, filePath string) error {
return expected
},
}
err := e.SaveMeasurement(&model.Measurement{}, "x")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
})
t.Run("SubmitAndUpdateMeasurementContext", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockSubmitAndUpdateMeasurementContext: func(ctx context.Context, measurement *model.Measurement) error {
return expected
},
}
err := e.SubmitAndUpdateMeasurementContext(context.Background(), &model.Measurement{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
})
t.Run("OpenReportContext", func(t *testing.T) {
expected := errors.New("mocked err")
e := &Experiment{
MockOpenReportContext: func(ctx context.Context) error {
return expected
},
}
err := e.OpenReportContext(context.Background())
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
})
}

View File

@ -0,0 +1,48 @@
package mocks
import "github.com/ooni/probe-cli/v3/internal/model"
// ExperimentBuilder mocks model.ExperimentBuilder.
type ExperimentBuilder struct {
MockInterruptible func() bool
MockInputPolicy func() model.InputPolicy
MockOptions func() (map[string]model.ExperimentOptionInfo, error)
MockSetOptionAny func(key string, value any) error
MockSetOptionsAny func(options map[string]any) error
MockSetCallbacks func(callbacks model.ExperimentCallbacks)
MockNewExperiment func() model.Experiment
}
func (eb *ExperimentBuilder) Interruptible() bool {
return eb.MockInterruptible()
}
func (eb *ExperimentBuilder) InputPolicy() model.InputPolicy {
return eb.MockInputPolicy()
}
func (eb *ExperimentBuilder) Options() (map[string]model.ExperimentOptionInfo, error) {
return eb.MockOptions()
}
func (eb *ExperimentBuilder) SetOptionAny(key string, value any) error {
return eb.MockSetOptionAny(key, value)
}
func (eb *ExperimentBuilder) SetOptionsAny(options map[string]any) error {
return eb.MockSetOptionsAny(options)
}
func (eb *ExperimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) {
eb.MockSetCallbacks(callbacks)
}
func (eb *ExperimentBuilder) NewExperiment() model.Experiment {
return eb.MockNewExperiment()
}

View File

@ -0,0 +1,99 @@
package mocks
import (
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestExperimentBuilder(t *testing.T) {
t.Run("Interruptible", func(t *testing.T) {
eb := &ExperimentBuilder{
MockInterruptible: func() bool {
return true
},
}
if !eb.Interruptible() {
t.Fatal("unexpected value")
}
})
t.Run("InputPolicy", func(t *testing.T) {
eb := &ExperimentBuilder{
MockInputPolicy: func() model.InputPolicy {
return model.InputOrQueryBackend
},
}
if eb.InputPolicy() != model.InputOrQueryBackend {
t.Fatal("unexpected value")
}
})
t.Run("Options", func(t *testing.T) {
expected := errors.New("mocked error")
eb := &ExperimentBuilder{
MockOptions: func() (map[string]model.ExperimentOptionInfo, error) {
return nil, expected
},
}
out, err := eb.Options()
if !errors.Is(err, expected) {
t.Fatal("unexpected value")
}
if len(out) > 0 {
t.Fatal("unexpected value")
}
})
t.Run("SetOptionAny", func(t *testing.T) {
expected := errors.New("mocked error")
eb := &ExperimentBuilder{
MockSetOptionAny: func(key string, value any) error {
return expected
},
}
err := eb.SetOptionAny("antani", 1245678)
if !errors.Is(err, expected) {
t.Fatal("unexpected value")
}
})
t.Run("SetOptionsAny", func(t *testing.T) {
expected := errors.New("mocked error")
eb := &ExperimentBuilder{
MockSetOptionsAny: func(options map[string]any) error {
return expected
},
}
err := eb.SetOptionsAny(make(map[string]any))
if !errors.Is(err, expected) {
t.Fatal("unexpected value")
}
})
t.Run("SetCallbacks", func(t *testing.T) {
var called bool
eb := &ExperimentBuilder{
MockSetCallbacks: func(callbacks model.ExperimentCallbacks) {
called = true
},
}
eb.SetCallbacks(model.NewPrinterCallbacks(model.DiscardLogger))
if !called {
t.Fatal("not called")
}
})
t.Run("NewExperiment", func(t *testing.T) {
exp := &Experiment{}
eb := &ExperimentBuilder{
MockNewExperiment: func() model.Experiment {
return exp
},
}
if out := eb.NewExperiment(); out != exp {
t.Fatal("invalid result")
}
})
}

View File

@ -0,0 +1,19 @@
package mocks
import (
"context"
"github.com/ooni/probe-cli/v3/internal/model"
)
// ExperimentInputLoader mocks model.ExperimentInputLoader
type ExperimentInputLoader struct {
MockLoad func(ctx context.Context) ([]model.OOAPIURLInfo, error)
}
var _ model.ExperimentInputLoader = &ExperimentInputLoader{}
// Load calls MockLoad
func (eil *ExperimentInputLoader) Load(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return eil.MockLoad(ctx)
}

View File

@ -0,0 +1,27 @@
package mocks
import (
"context"
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestExperimentInputLoader(t *testing.T) {
t.Run("Load", func(t *testing.T) {
expected := errors.New("mocked error")
eil := &ExperimentInputLoader{
MockLoad: func(ctx context.Context) ([]model.OOAPIURLInfo, error) {
return nil, expected
},
}
out, err := eil.Load(context.Background())
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if len(out) > 0 {
t.Fatal("unexpected length")
}
})
}

View File

@ -0,0 +1,12 @@
package mocks
import "context"
// ExperimentInputProcessor processes inputs running the given experiment.
type ExperimentInputProcessor struct {
MockRun func(ctx context.Context) error
}
func (eip *ExperimentInputProcessor) Run(ctx context.Context) error {
return eip.MockRun(ctx)
}

View File

@ -0,0 +1,22 @@
package mocks
import (
"context"
"errors"
"testing"
)
func TestExperimentInputProcessor(t *testing.T) {
t.Run("Run", func(t *testing.T) {
expected := errors.New("mocked error")
eip := &ExperimentInputProcessor{
MockRun: func(ctx context.Context) error {
return expected
},
}
err := eip.Run(context.Background())
if !errors.Is(err, expected) {
t.Fatal("unexpected result")
}
})
}

View File

@ -0,0 +1,12 @@
package mocks
import "github.com/ooni/probe-cli/v3/internal/model"
// Saver saves a measurement on some persistent storage.
type Saver struct {
MockSaveMeasurement func(m *model.Measurement) error
}
func (s *Saver) SaveMeasurement(m *model.Measurement) error {
return s.MockSaveMeasurement(m)
}

View File

@ -0,0 +1,23 @@
package mocks
import (
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestSaver(t *testing.T) {
t.Run("SaveMeasurement", func(t *testing.T) {
expected := errors.New("mocked error")
s := &Saver{
MockSaveMeasurement: func(m *model.Measurement) error {
return expected
},
}
err := s.SaveMeasurement(&model.Measurement{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
})
}

View File

@ -0,0 +1,161 @@
package mocks
import (
"context"
"net/url"
"github.com/ooni/probe-cli/v3/internal/model"
)
// Session allows to mock sessions.
type Session struct {
MockGetTestHelpersByName func(name string) ([]model.OOAPIService, bool)
MockDefaultHTTPClient func() model.HTTPClient
MockFetchPsiphonConfig func(ctx context.Context) ([]byte, error)
MockFetchTorTargets func(
ctx context.Context, cc string) (map[string]model.OOAPITorTarget, error)
MockFetchURLList func(
ctx context.Context, config model.OOAPIURLListConfig) ([]model.OOAPIURLInfo, error)
MockKeyValueStore func() model.KeyValueStore
MockLogger func() model.Logger
MockMaybeResolverIP func() string
MockProbeASNString func() string
MockProbeCC func() string
MockProbeIP func() string
MockProbeNetworkName func() string
MockProxyURL func() *url.URL
MockResolverIP func() string
MockSoftwareName func() string
MockSoftwareVersion func() string
MockTempDir func() string
MockTorArgs func() []string
MockTorBinary func() string
MockTunnelDir func() string
MockUserAgent func() string
MockNewExperimentBuilder func(name string) (model.ExperimentBuilder, error)
MockNewSubmitter func(ctx context.Context) (model.Submitter, error)
MockCheckIn func(ctx context.Context,
config *model.OOAPICheckInConfig) (*model.OOAPICheckInInfo, error)
}
func (sess *Session) GetTestHelpersByName(name string) ([]model.OOAPIService, bool) {
return sess.MockGetTestHelpersByName(name)
}
func (sess *Session) DefaultHTTPClient() model.HTTPClient {
return sess.MockDefaultHTTPClient()
}
func (sess *Session) FetchPsiphonConfig(ctx context.Context) ([]byte, error) {
return sess.MockFetchPsiphonConfig(ctx)
}
func (sess *Session) FetchTorTargets(
ctx context.Context, cc string) (map[string]model.OOAPITorTarget, error) {
return sess.MockFetchTorTargets(ctx, cc)
}
func (sess *Session) FetchURLList(
ctx context.Context, config model.OOAPIURLListConfig) ([]model.OOAPIURLInfo, error) {
return sess.MockFetchURLList(ctx, config)
}
func (sess *Session) KeyValueStore() model.KeyValueStore {
return sess.MockKeyValueStore()
}
func (sess *Session) Logger() model.Logger {
return sess.MockLogger()
}
func (sess *Session) MaybeResolverIP() string {
return sess.MockMaybeResolverIP()
}
func (sess *Session) ProbeASNString() string {
return sess.MockProbeASNString()
}
func (sess *Session) ProbeCC() string {
return sess.MockProbeCC()
}
func (sess *Session) ProbeIP() string {
return sess.MockProbeIP()
}
func (sess *Session) ProbeNetworkName() string {
return sess.MockProbeNetworkName()
}
func (sess *Session) ProxyURL() *url.URL {
return sess.MockProxyURL()
}
func (sess *Session) ResolverIP() string {
return sess.MockResolverIP()
}
func (sess *Session) SoftwareName() string {
return sess.MockSoftwareName()
}
func (sess *Session) SoftwareVersion() string {
return sess.MockSoftwareVersion()
}
func (sess *Session) TempDir() string {
return sess.MockTempDir()
}
func (sess *Session) TorArgs() []string {
return sess.MockTorArgs()
}
func (sess *Session) TorBinary() string {
return sess.MockTorBinary()
}
func (sess *Session) TunnelDir() string {
return sess.MockTunnelDir()
}
func (sess *Session) UserAgent() string {
return sess.MockUserAgent()
}
func (sess *Session) NewExperimentBuilder(name string) (model.ExperimentBuilder, error) {
return sess.MockNewExperimentBuilder(name)
}
func (sess *Session) NewSubmitter(ctx context.Context) (model.Submitter, error) {
return sess.MockNewSubmitter(ctx)
}
func (sess *Session) CheckIn(ctx context.Context,
config *model.OOAPICheckInConfig) (*model.OOAPICheckInInfo, error) {
return sess.MockCheckIn(ctx, config)
}

View File

@ -0,0 +1,357 @@
package mocks
import (
"context"
"errors"
"net/url"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/runtimex"
"github.com/ooni/probe-cli/v3/internal/testingx"
)
func TestSession(t *testing.T) {
t.Run("GetTestHelpersByName", func(t *testing.T) {
var expect []model.OOAPIService
ff := &testingx.FakeFiller{}
ff.Fill(&expect)
runtimex.Assert(len(expect) > 0, "expected non-empty array")
s := &Session{
MockGetTestHelpersByName: func(name string) ([]model.OOAPIService, bool) {
return expect, len(expect) > 0
},
}
out, good := s.GetTestHelpersByName("xx")
if !good {
t.Fatal("not good")
}
if diff := cmp.Diff(expect, out); diff != "" {
t.Fatal(diff)
}
})
t.Run("DefaultHTTPClient", func(t *testing.T) {
expected := &HTTPClient{}
s := &Session{
MockDefaultHTTPClient: func() model.HTTPClient {
return expected
},
}
out := s.DefaultHTTPClient()
if expected != out {
t.Fatal("unexpected result")
}
})
t.Run("FetchPsiphonConfig", func(t *testing.T) {
var expected []byte
ff := &testingx.FakeFiller{}
ff.Fill(&expected)
runtimex.Assert(len(expected) > 0, "expected nonempty list")
s := &Session{
MockFetchPsiphonConfig: func(ctx context.Context) ([]byte, error) {
return expected, nil
},
}
out, err := s.FetchPsiphonConfig(context.Background())
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(expected, out); diff != "" {
t.Fatal(diff)
}
})
t.Run("FetchTorTargets", func(t *testing.T) {
expected := errors.New("mocked err")
s := &Session{
MockFetchTorTargets: func(ctx context.Context, cc string) (map[string]model.OOAPITorTarget, error) {
return nil, expected
},
}
out, err := s.FetchTorTargets(context.Background(), "IT")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if len(out) > 0 {
t.Fatal("expected empty out")
}
})
t.Run("FetchURLList", func(t *testing.T) {
expected := errors.New("mocked err")
s := &Session{
MockFetchURLList: func(ctx context.Context, config model.OOAPIURLListConfig) ([]model.OOAPIURLInfo, error) {
return nil, expected
},
}
out, err := s.FetchURLList(context.Background(), model.OOAPIURLListConfig{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if len(out) > 0 {
t.Fatal("expected empty out")
}
})
t.Run("KeyValueStore", func(t *testing.T) {
expect := &KeyValueStore{}
s := &Session{
MockKeyValueStore: func() model.KeyValueStore {
return expect
},
}
out := s.KeyValueStore()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("Logger", func(t *testing.T) {
expect := &Logger{}
s := &Session{
MockLogger: func() model.Logger {
return expect
},
}
out := s.Logger()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("MaybeResolverIP", func(t *testing.T) {
expect := "xx"
s := &Session{
MockMaybeResolverIP: func() string {
return expect
},
}
out := s.MaybeResolverIP()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProbeASNString", func(t *testing.T) {
expect := "xx"
s := &Session{
MockProbeASNString: func() string {
return expect
},
}
out := s.ProbeASNString()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProbeCC", func(t *testing.T) {
expect := "xx"
s := &Session{
MockProbeCC: func() string {
return expect
},
}
out := s.ProbeCC()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProbeIP", func(t *testing.T) {
expect := "xx"
s := &Session{
MockProbeIP: func() string {
return expect
},
}
out := s.ProbeIP()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProbeNetworkName", func(t *testing.T) {
expect := "xx"
s := &Session{
MockProbeNetworkName: func() string {
return expect
},
}
out := s.ProbeNetworkName()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ProxyURL", func(t *testing.T) {
expect := &url.URL{Scheme: "xx"}
s := &Session{
MockProxyURL: func() *url.URL {
return expect
},
}
out := s.ProxyURL()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("ResolverIP", func(t *testing.T) {
expect := "xx"
s := &Session{
MockResolverIP: func() string {
return expect
},
}
out := s.ResolverIP()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("SoftwareName", func(t *testing.T) {
expect := "xx"
s := &Session{
MockSoftwareName: func() string {
return expect
},
}
out := s.SoftwareName()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("SoftwareVersion", func(t *testing.T) {
expect := "xx"
s := &Session{
MockSoftwareVersion: func() string {
return expect
},
}
out := s.SoftwareVersion()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("TempDir", func(t *testing.T) {
expect := "xx"
s := &Session{
MockTempDir: func() string {
return expect
},
}
out := s.TempDir()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("TorArgs", func(t *testing.T) {
var expect []string
ff := &testingx.FakeFiller{}
ff.Fill(&expect)
runtimex.Assert(len(expect) > 0, "expected non empty slice")
s := &Session{
MockTorArgs: func() []string {
return expect
},
}
out := s.TorArgs()
if diff := cmp.Diff(expect, out); diff != "" {
t.Fatal(diff)
}
})
t.Run("TorBinary", func(t *testing.T) {
expect := "xx"
s := &Session{
MockTorBinary: func() string {
return expect
},
}
out := s.TorBinary()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("TunnelDir", func(t *testing.T) {
expect := "xx"
s := &Session{
MockTunnelDir: func() string {
return expect
},
}
out := s.TunnelDir()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("UserAgent", func(t *testing.T) {
expect := "xx"
s := &Session{
MockUserAgent: func() string {
return expect
},
}
out := s.UserAgent()
if out != expect {
t.Fatal("invalid output")
}
})
t.Run("NewExperimentBuilder", func(t *testing.T) {
eb := &ExperimentBuilder{}
s := &Session{
MockNewExperimentBuilder: func(name string) (model.ExperimentBuilder, error) {
return eb, nil
},
}
out, err := s.NewExperimentBuilder("x")
if err != nil {
t.Fatal(err)
}
if out != eb {
t.Fatal("invalid output")
}
})
t.Run("NewSubmitter", func(t *testing.T) {
expected := errors.New("mocked err")
s := &Session{
MockNewSubmitter: func(ctx context.Context) (model.Submitter, error) {
return nil, expected
},
}
out, err := s.NewSubmitter(context.Background())
if !errors.Is(err, expected) {
t.Fatal("unexpected err")
}
if out != nil {
t.Fatal("unexpected out")
}
})
t.Run("CheckIn", func(t *testing.T) {
expected := errors.New("mocked err")
s := &Session{
MockCheckIn: func(ctx context.Context, config *model.OOAPICheckInConfig) (*model.OOAPICheckInInfo, error) {
return nil, expected
},
}
out, err := s.CheckIn(context.Background(), &model.OOAPICheckInConfig{})
if !errors.Is(err, expected) {
t.Fatal("unexpected err")
}
if out != nil {
t.Fatal("unexpected out")
}
})
}

View File

@ -0,0 +1,17 @@
package mocks
import (
"context"
"github.com/ooni/probe-cli/v3/internal/model"
)
// Submitter mocks model.Submitter.
type Submitter struct {
MockSubmit func(ctx context.Context, m *model.Measurement) error
}
// Submit calls MockSubmit
func (s *Submitter) Submit(ctx context.Context, m *model.Measurement) error {
return s.MockSubmit(ctx, m)
}

View File

@ -0,0 +1,24 @@
package mocks
import (
"context"
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/model"
)
func TestSubmitter(t *testing.T) {
t.Run("Submit", func(t *testing.T) {
expect := errors.New("mocked error")
s := &Submitter{
MockSubmit: func(ctx context.Context, m *model.Measurement) error {
return expect
},
}
err := s.Submit(context.Background(), &model.Measurement{})
if !errors.Is(err, expect) {
t.Fatal("unexpected err", err)
}
})
}

View File

@ -613,9 +613,9 @@ func dnsGenLookupHostReplySuccess(rawQuery []byte, cname *dnsCNAMEAnswer, ips ..
query := new(dns.Msg) query := new(dns.Msg)
err := query.Unpack(rawQuery) err := query.Unpack(rawQuery)
runtimex.PanicOnError(err, "query.Unpack failed") runtimex.PanicOnError(err, "query.Unpack failed")
runtimex.PanicIfFalse(len(query.Question) == 1, "more than one question") runtimex.Assert(len(query.Question) == 1, "more than one question")
question := query.Question[0] question := query.Question[0]
runtimex.PanicIfFalse( runtimex.Assert(
question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA, question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA,
"invalid query type (expected A or AAAA)", "invalid query type (expected A or AAAA)",
) )
@ -669,9 +669,9 @@ func dnsGenHTTPSReplySuccess(rawQuery []byte, alpns, ipv4s, ipv6s []string) []by
query := new(dns.Msg) query := new(dns.Msg)
err := query.Unpack(rawQuery) err := query.Unpack(rawQuery)
runtimex.PanicOnError(err, "query.Unpack failed") runtimex.PanicOnError(err, "query.Unpack failed")
runtimex.PanicIfFalse(len(query.Question) == 1, "expected just a single question") runtimex.Assert(len(query.Question) == 1, "expected just a single question")
question := query.Question[0] question := query.Question[0]
runtimex.PanicIfFalse(question.Qtype == dns.TypeHTTPS, "expected HTTPS query") runtimex.Assert(question.Qtype == dns.TypeHTTPS, "expected HTTPS query")
reply := new(dns.Msg) reply := new(dns.Msg)
reply.Compress = true reply.Compress = true
reply.MsgHdr.RecursionAvailable = true reply.MsgHdr.RecursionAvailable = true
@ -716,9 +716,9 @@ func dnsGenNSReplySuccess(rawQuery []byte, names ...string) []byte {
query := new(dns.Msg) query := new(dns.Msg)
err := query.Unpack(rawQuery) err := query.Unpack(rawQuery)
runtimex.PanicOnError(err, "query.Unpack failed") runtimex.PanicOnError(err, "query.Unpack failed")
runtimex.PanicIfFalse(len(query.Question) == 1, "more than one question") runtimex.Assert(len(query.Question) == 1, "more than one question")
question := query.Question[0] question := query.Question[0]
runtimex.PanicIfFalse(question.Qtype == dns.TypeNS, "expected NS query") runtimex.Assert(question.Qtype == dns.TypeNS, "expected NS query")
reply := new(dns.Msg) reply := new(dns.Msg)
reply.Compress = true reply.Compress = true
reply.MsgHdr.RecursionAvailable = true reply.MsgHdr.RecursionAvailable = true

View File

@ -104,7 +104,7 @@ func NewDefaultCertPool() *x509.CertPool {
// Assumption: AppendCertsFromPEM cannot fail because we // Assumption: AppendCertsFromPEM cannot fail because we
// have a test in certify_test.go that guarantees that // have a test in certify_test.go that guarantees that
ok := pool.AppendCertsFromPEM([]byte(pemcerts)) ok := pool.AppendCertsFromPEM([]byte(pemcerts))
runtimex.PanicIfFalse(ok, "pool.AppendCertsFromPEM failed") runtimex.Assert(ok, "pool.AppendCertsFromPEM failed")
return pool return pool
} }

View File

@ -11,11 +11,15 @@ import (
"strings" "strings"
"time" "time"
"github.com/ooni/probe-cli/v3/internal/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine" "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/humanize" "github.com/ooni/probe-cli/v3/internal/humanize"
"github.com/ooni/probe-cli/v3/internal/model" "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 // Experiment describes an experiment to run. You MUST fill all the fields that
// are marked as MANDATORY, otherwise Experiment.Run will cause panics. // are marked as MANDATORY, otherwise Experiment.Run will cause panics.
type Experiment struct { type Experiment struct {
@ -52,6 +56,22 @@ type Experiment struct {
// Session is the MANDATORY session. // Session is the MANDATORY session.
Session 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. // 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) { rnd.Shuffle(len(inputList), func(i, j int) {
inputList[i], inputList[j] = inputList[j], inputList[i] inputList[i], inputList[j] = inputList[j], inputList[i]
}) })
experimentShuffledInputs.Add(1)
} }
// 4. configure experiment's options // 4. configure experiment's options
@ -112,14 +133,15 @@ func (ed *Experiment) Run(ctx context.Context) error {
return inputProcessor.Run(ctx) return inputProcessor.Run(ctx)
} }
// inputProcessor processes inputs running the given experiment. // inputProcessor is an alias for model.ExperimentInputProcessor
type inputProcessor interface { type inputProcessor = model.ExperimentInputProcessor
Run(ctx context.Context) error
}
// newInputProcessor creates a new inputProcessor instance. // newInputProcessor creates a new inputProcessor instance.
func (ed *Experiment) newInputProcessor(experiment model.Experiment, func (ed *Experiment) newInputProcessor(experiment model.Experiment,
inputList []model.OOAPIURLInfo, saver engine.Saver, submitter engine.Submitter) inputProcessor { inputList []model.OOAPIURLInfo, saver engine.Saver, submitter engine.Submitter) inputProcessor {
if ed.newInputProcessorFn != nil {
return ed.newInputProcessorFn(experiment, inputList, saver, submitter)
}
return &engine.InputProcessor{ return &engine.InputProcessor{
Annotations: ed.Annotations, Annotations: ed.Annotations,
Experiment: &experimentWrapper{ Experiment: &experimentWrapper{
@ -140,6 +162,9 @@ func (ed *Experiment) newInputProcessor(experiment model.Experiment,
// newSaver creates a new engine.Saver instance. // newSaver creates a new engine.Saver instance.
func (ed *Experiment) newSaver(experiment model.Experiment) (engine.Saver, error) { func (ed *Experiment) newSaver(experiment model.Experiment) (engine.Saver, error) {
if ed.newSaverFn != nil {
return ed.newSaverFn(experiment)
}
return engine.NewSaver(engine.SaverConfig{ return engine.NewSaver(engine.SaverConfig{
Enabled: !ed.NoJSON, Enabled: !ed.NoJSON,
Experiment: experiment, Experiment: experiment,
@ -150,6 +175,9 @@ func (ed *Experiment) newSaver(experiment model.Experiment) (engine.Saver, error
// newSubmitter creates a new engine.Submitter instance. // newSubmitter creates a new engine.Submitter instance.
func (ed *Experiment) newSubmitter(ctx context.Context) (engine.Submitter, error) { func (ed *Experiment) newSubmitter(ctx context.Context) (engine.Submitter, error) {
if ed.newSubmitterFn != nil {
return ed.newSubmitterFn(ctx)
}
return engine.NewSubmitter(ctx, engine.SubmitterConfig{ return engine.NewSubmitter(ctx, engine.SubmitterConfig{
Enabled: !ed.NoCollector, Enabled: !ed.NoCollector,
Session: ed.Session, 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. // newExperimentBuilder creates a new engine.ExperimentBuilder for the given experimentName.
func (ed *Experiment) newExperimentBuilder(experimentName string) (model.ExperimentBuilder, error) { func (ed *Experiment) newExperimentBuilder(experimentName string) (model.ExperimentBuilder, error) {
if ed.newExperimentBuilderFn != nil {
return ed.newExperimentBuilderFn(experimentName)
}
return ed.Session.NewExperimentBuilder(ed.Name) return ed.Session.NewExperimentBuilder(ed.Name)
} }
// inputLoader loads inputs from local or remote sources. // inputLoader is an alias for model.ExperimentInputLoader
type inputLoader interface { type inputLoader = model.ExperimentInputLoader
Load(ctx context.Context) ([]model.OOAPIURLInfo, error)
}
// newInputLoader creates a new inputLoader. // newInputLoader creates a new inputLoader.
func (ed *Experiment) newInputLoader(inputPolicy model.InputPolicy) inputLoader { func (ed *Experiment) newInputLoader(inputPolicy model.InputPolicy) inputLoader {
if ed.newInputLoaderFn != nil {
return ed.newInputLoaderFn(inputPolicy)
}
return &engine.InputLoader{ return &engine.InputLoader{
CheckInConfig: &model.OOAPICheckInConfig{ CheckInConfig: &model.OOAPICheckInConfig{
RunType: model.RunTypeManual, RunType: model.RunTypeManual,

View File

@ -2,42 +2,24 @@ package oonirun
import ( import (
"context" "context"
"os" "errors"
"reflect" "reflect"
"sort" "sort"
"testing" "testing"
"time" "time"
"github.com/ooni/probe-cli/v3/internal/engine" "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/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 func TestExperimentRunWithFailureToSubmitAndShuffle(t *testing.T) {
// we need to ~redesign the engine package for unit-testability. shuffledInputsPrev := experimentShuffledInputs.Load()
var calledSetOptionsAny int
func newSession(ctx context.Context, t *testing.T) *engine.Session { var failedToSubmit int
config := engine.SessionConfig{ var calledKibiBytesReceived int
AvailableProbeServices: []model.OOAPIService{}, var calledKibiBytesSent int
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) {
ctx := context.Background() ctx := context.Background()
desc := &Experiment{ desc := &Experiment{
Annotations: map[string]string{ Annotations: map[string]string{
@ -46,19 +28,89 @@ func TestExperimentRunWithExample(t *testing.T) {
ExtraOptions: map[string]any{ ExtraOptions: map[string]any{
"SleepTime": int64(10 * time.Millisecond), "SleepTime": int64(10 * time.Millisecond),
}, },
Inputs: []string{}, Inputs: []string{
"a", "b", "c",
},
InputFilePaths: []string{}, InputFilePaths: []string{},
MaxRuntime: 0, MaxRuntime: 0,
Name: "example", Name: "example",
NoCollector: true, NoCollector: true,
NoJSON: true, NoJSON: true,
Random: false, Random: true, // to test randomness
ReportFile: "", 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 { if err := desc.Run(ctx); err != nil {
t.Fatal(err) 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) { 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)
}
})
}
}

View File

@ -8,6 +8,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/url" "net/url"
) )
@ -50,7 +51,7 @@ func v1Measure(ctx context.Context, config *LinkConfig, URL string) error {
if pu.Host != "nettest" { if pu.Host != "nettest" {
return ErrInvalidV1URLHost return ErrInvalidV1URLHost
} }
if pu.Path != "" { if pu.Path != "" && pu.Path != "/" {
return ErrInvalidV1URLPath return ErrInvalidV1URLPath
} }
default: default:
@ -58,33 +59,50 @@ func v1Measure(ctx context.Context, config *LinkConfig, URL string) error {
} }
name := pu.Query().Get("tn") name := pu.Query().Get("tn")
if name == "" { if name == "" {
return ErrInvalidV1URLQueryArgument return fmt.Errorf("%w: empty test name", ErrInvalidV1URLQueryArgument)
} }
var inputs []string var inputs []string
if ra := pu.Query().Get("ta"); ra != "" { if ta := pu.Query().Get("ta"); ta != "" {
pa, err := url.QueryUnescape(ra) inputs, err = v1ParseArguments(ta)
if err != nil { if err != nil {
return err 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{ exp := &Experiment{
Annotations: config.Annotations, Annotations: config.Annotations,
ExtraOptions: nil, // no way to specify with v1 URLs ExtraOptions: nil, // no way to specify with v1 URLs
Inputs: inputs, Inputs: inputs,
InputFilePaths: nil, InputFilePaths: nil,
MaxRuntime: config.MaxRuntime, MaxRuntime: config.MaxRuntime,
Name: name, Name: name,
NoCollector: config.NoCollector, NoCollector: config.NoCollector,
NoJSON: config.NoJSON, NoJSON: config.NoJSON,
Random: config.Random, Random: config.Random,
ReportFile: config.ReportFile, ReportFile: config.ReportFile,
Session: config.Session, Session: config.Session,
newExperimentBuilderFn: nil,
newInputLoaderFn: nil,
newSubmitterFn: nil,
newSaverFn: nil,
newInputProcessorFn: nil,
} }
return exp.Run(ctx) 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
}

View File

@ -2,13 +2,60 @@ package oonirun
import ( import (
"context" "context"
"errors"
"net/http"
"strings"
"testing" "testing"
"github.com/ooni/probe-cli/v3/internal/kvstore" "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 func newMinimalFakeSession() *mocks.Session {
// we need to ~redesign the engine package for unit-testability. 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) { func TestOONIRunV1Link(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@ -23,7 +70,7 @@ func TestOONIRunV1Link(t *testing.T) {
NoJSON: true, NoJSON: true,
Random: false, Random: false,
ReportFile: "", ReportFile: "",
Session: newSession(ctx, t), Session: newMinimalFakeSession(),
} }
r := NewLinkRunner(config, "https://run.ooni.io/nettest?tn=example&mv=1.2.0") r := NewLinkRunner(config, "https://run.ooni.io/nettest?tn=example&mv=1.2.0")
if err := r.Run(ctx); err != nil { if err := r.Run(ctx); err != nil {
@ -34,3 +81,188 @@ func TestOONIRunV1Link(t *testing.T) {
t.Fatal(err) 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)
}
})
}

View File

@ -24,6 +24,10 @@ var (
// v2CountEmptyNettestNames counts the number of cases in which we have been // v2CountEmptyNettestNames counts the number of cases in which we have been
// given an empty nettest name, which is useful for testing. // given an empty nettest name, which is useful for testing.
v2CountEmptyNettestNames = &atomicx.Int64{} 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. // v2Descriptor describes a single nettest to run.
@ -172,20 +176,26 @@ func v2MeasureDescriptor(ctx context.Context, config *LinkConfig, desc *v2Descri
continue continue
} }
exp := &Experiment{ exp := &Experiment{
Annotations: config.Annotations, Annotations: config.Annotations,
ExtraOptions: nettest.Options, ExtraOptions: nettest.Options,
Inputs: nettest.Inputs, Inputs: nettest.Inputs,
InputFilePaths: nil, InputFilePaths: nil,
MaxRuntime: config.MaxRuntime, MaxRuntime: config.MaxRuntime,
Name: nettest.TestName, Name: nettest.TestName,
NoCollector: config.NoCollector, NoCollector: config.NoCollector,
NoJSON: config.NoJSON, NoJSON: config.NoJSON,
Random: config.Random, Random: config.Random,
ReportFile: config.ReportFile, ReportFile: config.ReportFile,
Session: config.Session, Session: config.Session,
newExperimentBuilderFn: nil,
newInputLoaderFn: nil,
newSubmitterFn: nil,
newSaverFn: nil,
newInputProcessorFn: nil,
} }
if err := exp.Run(ctx); err != nil { if err := exp.Run(ctx); err != nil {
logger.Warnf("cannot run experiment: %s", err.Error()) logger.Warnf("cannot run experiment: %s", err.Error())
v2CountFailedExperiments.Add(1)
continue continue
} }
} }

View File

@ -10,13 +10,11 @@ import (
"time" "time"
"github.com/ooni/probe-cli/v3/internal/kvstore" "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/model/mocks"
"github.com/ooni/probe-cli/v3/internal/runtimex" "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) { func TestOONIRunV2LinkCommonCase(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
descriptor := &v2Descriptor{ descriptor := &v2Descriptor{
@ -48,7 +46,7 @@ func TestOONIRunV2LinkCommonCase(t *testing.T) {
NoJSON: true, NoJSON: true,
Random: false, Random: false,
ReportFile: "", ReportFile: "",
Session: newSession(ctx, t), Session: newMinimalFakeSession(),
} }
r := NewLinkRunner(config, server.URL) r := NewLinkRunner(config, server.URL)
if err := r.Run(ctx); err != nil { if err := r.Run(ctx); err != nil {
@ -95,7 +93,7 @@ func TestOONIRunV2LinkCannotUpdateCache(t *testing.T) {
NoJSON: true, NoJSON: true,
Random: false, Random: false,
ReportFile: "", ReportFile: "",
Session: newSession(ctx, t), Session: newMinimalFakeSession(),
} }
r := NewLinkRunner(config, server.URL) r := NewLinkRunner(config, server.URL)
err := r.Run(ctx) err := r.Run(ctx)
@ -135,7 +133,7 @@ func TestOONIRunV2LinkWithoutAcceptChanges(t *testing.T) {
NoJSON: true, NoJSON: true,
Random: false, Random: false,
ReportFile: "", ReportFile: "",
Session: newSession(ctx, t), Session: newMinimalFakeSession(),
} }
r := NewLinkRunner(config, server.URL) r := NewLinkRunner(config, server.URL)
err := r.Run(ctx) err := r.Run(ctx)
@ -161,7 +159,7 @@ func TestOONIRunV2LinkNilDescriptor(t *testing.T) {
NoJSON: true, NoJSON: true,
Random: false, Random: false,
ReportFile: "", ReportFile: "",
Session: newSession(ctx, t), Session: newMinimalFakeSession(),
} }
r := NewLinkRunner(config, server.URL) r := NewLinkRunner(config, server.URL)
if err := r.Run(ctx); err != nil { if err := r.Run(ctx); err != nil {
@ -170,6 +168,7 @@ func TestOONIRunV2LinkNilDescriptor(t *testing.T) {
} }
func TestOONIRunV2LinkEmptyTestName(t *testing.T) { func TestOONIRunV2LinkEmptyTestName(t *testing.T) {
emptyTestNamesPrev := v2CountEmptyNettestNames.Load()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
descriptor := &v2Descriptor{ descriptor := &v2Descriptor{
Name: "", Name: "",
@ -200,14 +199,14 @@ func TestOONIRunV2LinkEmptyTestName(t *testing.T) {
NoJSON: true, NoJSON: true,
Random: false, Random: false,
ReportFile: "", ReportFile: "",
Session: newSession(ctx, t), Session: newMinimalFakeSession(),
} }
r := NewLinkRunner(config, server.URL) r := NewLinkRunner(config, server.URL)
if err := r.Run(ctx); err != nil { if err := r.Run(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if v2CountEmptyNettestNames.Load() != 1 { if v2CountEmptyNettestNames.Load() != emptyTestNamesPrev+1 {
t.Fatal("expected to see 1 instance of empty nettest names") 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.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) { func TestV2MeasureHTTPS(t *testing.T) {
@ -239,7 +306,7 @@ func TestV2MeasureHTTPS(t *testing.T) {
NoJSON: false, NoJSON: false,
Random: false, Random: false,
ReportFile: "", ReportFile: "",
Session: newSession(ctx, t), Session: newMinimalFakeSession(),
} }
err := v2MeasureHTTPS(ctx, config, "") err := v2MeasureHTTPS(ctx, config, "")
if !errors.Is(err, expected) { if !errors.Is(err, expected) {
@ -259,7 +326,7 @@ func TestV2MeasureHTTPS(t *testing.T) {
NoJSON: false, NoJSON: false,
Random: false, Random: false,
ReportFile: "", ReportFile: "",
Session: newSession(ctx, t), Session: newMinimalFakeSession(),
} }
err := v2MeasureHTTPS(ctx, config, "https://example.com") // should not use URL err := v2MeasureHTTPS(ctx, config, "https://example.com") // should not use URL
if !errors.Is(err, context.Canceled) { if !errors.Is(err, context.Canceled) {

View File

@ -11,8 +11,8 @@ func PanicOnError(err error, message string) {
} }
} }
// PanicIfFalse calls panic if assertion is false. // Assert calls panic if assertion is false.
func PanicIfFalse(assertion bool, message string) { func Assert(assertion bool, message string) {
if !assertion { if !assertion {
panic(message) panic(message)
} }
@ -20,7 +20,7 @@ func PanicIfFalse(assertion bool, message string) {
// PanicIfTrue calls panic if assertion is true. // PanicIfTrue calls panic if assertion is true.
func PanicIfTrue(assertion bool, message string) { func PanicIfTrue(assertion bool, message string) {
PanicIfFalse(!assertion, message) Assert(!assertion, message)
} }
// PanicIfNil calls panic if the given interface is nil. // PanicIfNil calls panic if the given interface is nil.

View File

@ -28,17 +28,17 @@ func TestPanicOnError(t *testing.T) {
}) })
} }
func TestPanicIfFalse(t *testing.T) { func TestAssert(t *testing.T) {
badfunc := func(in bool, message string) (out error) { badfunc := func(in bool, message string) (out error) {
defer func() { defer func() {
out = errors.New(recover().(string)) out = errors.New(recover().(string))
}() }()
runtimex.PanicIfFalse(in, message) runtimex.Assert(in, message)
return return
} }
t.Run("assertion is true", func(t *testing.T) { t.Run("assertion is true", func(t *testing.T) {
runtimex.PanicIfFalse(true, "this assertion should not fail") runtimex.Assert(true, "this assertion should not fail")
}) })
t.Run("assertion is false", func(t *testing.T) { t.Run("assertion is false", func(t *testing.T) {

View File

@ -33,7 +33,7 @@ func NewFailureStr(err error) FailureStr {
if !errors.As(err, &errWrapper) { if !errors.As(err, &errWrapper) {
err := netxlite.NewTopLevelGenericErrWrapper(err) err := netxlite.NewTopLevelGenericErrWrapper(err)
couldConvert := errors.As(err, &errWrapper) couldConvert := errors.As(err, &errWrapper)
runtimex.PanicIfFalse(couldConvert, "we should have an ErrWrapper here") runtimex.Assert(couldConvert, "we should have an ErrWrapper here")
} }
s := errWrapper.Failure s := errWrapper.Failure
if s == "" { if s == "" {