diff --git a/cmd/dialoguss.go b/cmd/dialoguss.go index f89e22e..7c3ed02 100644 --- a/cmd/dialoguss.go +++ b/cmd/dialoguss.go @@ -6,6 +6,8 @@ import ( "math/rand" "net/http" "os" + "regexp" + "strings" "sync" "time" @@ -37,11 +39,17 @@ const ( // Dialoguss the main type for interacting with dialoguss sessions type Dialoguss core.Dialoguss +type ComponentStore map[string]map[string]string // namespace => component_id => expect + // UnexpectedResultError unexpected result from the USSD application func UnexpectedResultError(want string, have string) error { return fmt.Errorf("Unexpected result.\n\tWant: %s\n\tHave: %s", want, have) } +func InvalidComponentIdError(step *core.Step, session *core.Session) error { + return fmt.Errorf("Invalid component id on step %d of session %s", step.StepNo, session.ID) +} + // DialStep is the first step in the session, dials the USSD service func DialStep(expect string) *core.Step { return &core.Step{ @@ -104,8 +112,40 @@ func NewInteractiveSession(d core.DialogussConfig) *core.Session { } } +// ResolveStepExpectedValue extracts step's expected value from the step or component store +func ResolveStepExpectedValue(s *core.Session, step *core.Step, store *ComponentStore) (string, error) { + expect := step.Expect + + if len(expect) > 0 || len(step.ComponentID) == 0 { + return expect, nil + } + + path := strings.SplitN(step.ComponentID, "/", 2) + namespace := "default" + component_id := "" + + if len(path) == 2 { + namespace = path[0] + component_id = path[1] + } else { + component_id = path[0] + } + + container, ok := (*store)[namespace] + if !ok { + return "", InvalidComponentIdError(step, s) + } + + expect, ok = container[component_id] + if !ok { + return "", InvalidComponentIdError(step, s) + } + + return expect, nil +} + // Run runs the dialoguss session and executes the steps in each session -func Run(s *core.Session) error { +func Run(s *core.Session, store *ComponentStore) error { first := true for i, step := range s.Steps { if first { @@ -119,7 +159,13 @@ func Run(s *core.Session) error { log.Printf("Failed to execute step %d", step.StepNo) return err } - if result != step.Expect { + + expect, err := ResolveStepExpectedValue(s, step, store) + if err != nil { + return err + } + + if result != expect { return UnexpectedResultError(step.Expect, result) } } @@ -209,8 +255,42 @@ func (d *Dialoguss) LoadConfig() error { return yaml.Unmarshal(b, &d.Config) } +func (d *Dialoguss) GetComponents() (*ComponentStore, error) { + store := make(ComponentStore) + if d.Config.Components == nil { + return &store, nil + } + + id_pattern := regexp.MustCompile(`^[A-Za-z]+[A-Za-z0-9_-]*$`) + + for _, container := range d.Config.Components { + if !id_pattern.MatchString(container.Namespace) { + return nil, fmt.Errorf("Invalid component namespace: %s, expected alphanumeric chars only", container.Namespace) + } + + components, ok := store[container.Namespace] + if !ok { + components = make(map[string]string) + } + + for _, component := range container.Items { + if !id_pattern.MatchString(component.ID) { + return nil, fmt.Errorf("Invalid component ID: %s, expected alphanumeric chars only", component.ID) + } + + components[component.ID] = component.Expect + } + + store[container.Namespace] = components + } + + return &store, nil +} + // RunAutomatedSessions Loads the sessions for this application -func (d *Dialoguss) RunAutomatedSessions() error { +// +// Returns number of failed sessions +func (d *Dialoguss) RunAutomatedSessions() (int, error) { var wg sync.WaitGroup wg.Add(len(d.Config.Sessions)) @@ -221,6 +301,11 @@ func (d *Dialoguss) RunAutomatedSessions() error { apiType = ApiTypeTruroute } + components, err := d.GetComponents() + if err != nil { + return 0, err + } + for _, session := range d.Config.Sessions { steps := make([]*core.Step, len(session.Steps)) copy(steps, session.Steps) @@ -239,7 +324,7 @@ func (d *Dialoguss) RunAutomatedSessions() error { go func() { defer wg.Done() - err := Run(s) + err := Run(s, components) if err != nil { // sessionErrors <-fmt.Sprintf("Error in Session %s. Got: %s ", s.ID, err) sessionErrors[s.ID] = err @@ -250,7 +335,7 @@ func (d *Dialoguss) RunAutomatedSessions() error { for key, val := range sessionErrors { log.Printf("Got error in session %s: %s", key, val) } - return nil + return len(sessionErrors), nil } // Run executes the sessions @@ -261,5 +346,6 @@ func (d *Dialoguss) Run() error { return RunInteractive(session) } - return d.RunAutomatedSessions() + _, err := d.RunAutomatedSessions() + return err } diff --git a/cmd/dialoguss_test.go b/cmd/dialoguss_test.go new file mode 100644 index 0000000..e818173 --- /dev/null +++ b/cmd/dialoguss_test.go @@ -0,0 +1,272 @@ +package cmd + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nndi-oss/dialoguss/pkg/core" +) + +func FillComponentStore(store *ComponentStore, namespace string, components ...core.Component) { + if _, ok := (*store)[namespace]; !ok { + container := make(map[string]string) + (*store)[namespace] = container + } + + for _, component := range components { + (*store)[namespace][component.ID] = component.Expect + } +} + +func TestResolveStepExpectedValue(t *testing.T) { + t.Run("Step.Expect is used when no ComponentID is Provided", func(*testing.T) { + want := "Hello" + + session := core.Session{ID: "foo"} + step := core.Step{StepNo: 1, Expect: want} + store := make(ComponentStore) + FillComponentStore(&store, "default", core.Component{ID: "bar"}) + + returned, _ := ResolveStepExpectedValue(&session, &step, &store) + if returned != want { + t.Fatalf(`ResolveStepExpectedValue == "%s", want "%s"`, returned, want) + } + }) + + t.Run("Step.Expect overrides Component.Expect", func(*testing.T) { + want := "Chronicles of Narnia" + + session := core.Session{ID: "foo"} + step := core.Step{StepNo: 1, Expect: want, ComponentID: "default/bar"} + store := make(ComponentStore) + FillComponentStore(&store, "default", core.Component{ID: "bar"}) + + returned, _ := ResolveStepExpectedValue(&session, &step, &store) + if returned != want { + t.Fatalf(`ResolveStepExpectedValue == "%s", want "%s"`, returned, want) + } + }) + + t.Run("Component.Expect is used when Step.Expect is not provided", func(t *testing.T) { + want := "Thou shalt not test the lord thy God" + + session := core.Session{ID: "foo"} + step := core.Step{StepNo: 1, Expect: "", ComponentID: "default/bar"} + store := make(ComponentStore) + FillComponentStore(&store, "default", core.Component{ID: "bar", Expect: want}) + + returned, _ := ResolveStepExpectedValue(&session, &step, &store) + if returned != want { + t.Fatalf(`ResolveStepExpectedValue == "%s", want "%s"`, returned, want) + } + + }) + + t.Run("Errors when a non existent Step.ComponentID", func(*testing.T) { + session := core.Session{ID: "foo"} + step := core.Step{StepNo: 1, Expect: "", ComponentID: "foo/bar"} + store := make(ComponentStore) + FillComponentStore(&store, "foo", core.Component{ID: "foo", Expect: "Nada!"}) + + _, err := ResolveStepExpectedValue(&session, &step, &store) + if err == nil { + t.Fatalf("ResolveStepExpectedValue did not error") + } + }) + + t.Run("Uses default namespace when Step.ComponentID is not namespaced", func(*testing.T) { + want := "My patience is being tested here" + + session := core.Session{ID: "foobbar"} + step := core.Step{StepNo: 1, Expect: "", ComponentID: "bar"} + store := make(ComponentStore) + FillComponentStore(&store, "default", core.Component{ID: "bar", Expect: want}) + + returned, _ := ResolveStepExpectedValue(&session, &step, &store) + if returned != want { + t.Fatalf(`ResolveStepExpectedValue == "%s", want "%s"`, returned, want) + } + }) +} + +func CreateTestDialoguss(url string, steps []*core.Step, components []*core.Component) *Dialoguss { + return &Dialoguss{ + IsInteractive: false, + File: "/dev/null", + Config: core.DialogussConfig{ + URL: url, + Dial: "*6969#", + PhoneNumber: "0888800900", + Sessions: []core.Session{ + { + ID: "c-session", + PhoneNumber: "0888800900", + Description: "It's the gat damn session", + Steps: steps, + Url: url, + Client: &http.Client{}, + ApiType: ApiTypeAfricastalking, + Timeout: 1000, + }, + }, + Components: []core.ComponentNamespace{ + { + Namespace: "default", + Items: components, + }, + }, + }, + } +} + +func CreateMockHttpServer(status int, responses ...string) *httptest.Server { + responseNo := 0 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if responseNo >= len(responses) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("You are asking too many questions!")) + return + } + + anchor := "CON" + if responseNo < len(responses)-1 { + anchor = "END" + } + + response := fmt.Sprintf("%s %s", anchor, responses[responseNo]) + + w.WriteHeader(status) + w.Write([]byte(response)) + responseNo += 1 + })) +} + +func TestRunAutomatedSession(t *testing.T) { + t.Run("Passes steps with matched expectations", func(*testing.T) { + server := CreateMockHttpServer(http.StatusOK, "Welcome", "Hello") + defer server.Close() + + dialoguss := CreateTestDialoguss( + server.URL, + []*core.Step{ + { + Expect: "Welcome", + }, + { + Text: "1", + Expect: "Hello", + }, + }, + []*core.Component{}, + ) + failed, err := dialoguss.RunAutomatedSessions() + if err != nil { + t.Fatalf("Session run failed: %v", err) + } + + if failed != 0 { + t.Fatalf("Unexpected number of failed sessions, expected: 0, got: %d", failed) + } + }) + + t.Run("Fails steps with unmatched expectations", func(*testing.T) { + server := CreateMockHttpServer(http.StatusOK, "Welcome", "Hello") + defer server.Close() + + dialoguss := CreateTestDialoguss( + server.URL, + []*core.Step{ + { + Expect: "Mwalandilidwa", + }, + { + Expect: "Hello", + Text: "2", + }, + }, + []*core.Component{}, + ) + failed, err := dialoguss.RunAutomatedSessions() + if err != nil { + t.Fatalf("Session run failed: %v", err) + } + + if failed != 1 { + t.Fatalf("Unexpected number of failed sessions, expected: 1, got: %d", failed) + } + }) + + t.Run("Passes steps with matching component expectations", func(*testing.T) { + server := CreateMockHttpServer(http.StatusOK, "Welcome", "Hello") + defer server.Close() + + dialoguss := CreateTestDialoguss( + server.URL, + []*core.Step{ + { + ComponentID: "default.splash", + }, + { + ComponentID: "home", + Expect: "Hello", + }, + }, + []*core.Component{ + { + ID: "splash", + Expect: "Welcome", + }, + { + ID: "home", + Expect: "Hello", + }, + }, + ) + failed, err := dialoguss.RunAutomatedSessions() + if err != nil { + t.Fatalf("Session run failed: %v", err) + } + + if failed != 0 { + t.Fatalf("Unexpected number of failed sessions, expected: 0, got: %d", failed) + } + }) + + t.Run("Fails steps with non-matching component expectations", func(t *testing.T) { + server := CreateMockHttpServer(http.StatusOK, "Welcome", "Hello") + defer server.Close() + + dialoguss := CreateTestDialoguss( + server.URL, + []*core.Step{ + { + ComponentID: "default.splash", + }, + { + ComponentID: "home", + Expect: "Hello", + }, + }, + []*core.Component{ + { + ID: "splash", + Expect: "Mwalandilidwa", + }, + { + ID: "home", + Expect: "Hello", + }, + }, + ) + failed, err := dialoguss.RunAutomatedSessions() + if err != nil { + t.Fatalf("Session run failed: %v", err) + } + + if failed != 1 { + t.Fatalf("Unexpected number of failed sessions, expected: 1, got: %d", failed) + } + }) +} diff --git a/pkg/core/core.go b/pkg/core/core.go index f604cca..6fcc3a0 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -13,13 +13,24 @@ type Dialoguss struct { } type Step struct { - StepNo int - IsLast bool - IsDial bool - Text string `yaml:"userInput"` + StepNo int + IsLast bool + IsDial bool + Text string `yaml:"userInput"` + Expect string `yaml:"expect"` + ComponentID string `yaml:"componentId"` +} + +type Component struct { + ID string `yaml:"id"` Expect string `yaml:"expect"` } +type ComponentNamespace struct { + Namespace string `yaml:"namespace"` + Items []*Component `yaml:"component"` +} + type Session struct { ID string `yaml:"id"` PhoneNumber string `yaml:"phoneNumber"` @@ -33,9 +44,10 @@ type Session struct { } type DialogussConfig struct { - URL string `yaml:"url"` - Dial string `yaml:"dial"` - PhoneNumber string `yaml:"phoneNumber"` - Sessions []Session `yaml:"sessions"` - Timeout int `yaml:"timeout"` + URL string `yaml:"url"` + Dial string `yaml:"dial"` + PhoneNumber string `yaml:"phoneNumber"` + Sessions []Session `yaml:"sessions"` + Components []ComponentNamespace `yaml:"components"` + Timeout int `yaml:"timeout"` }