From 1784ebb635a9a0f68122547181701e0a6d412128 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 22:27:18 -0500 Subject: [PATCH 01/46] rewrite mocktwilio with assertions --- devtools/mocktwilio/assert.go | 145 ++++++++ devtools/mocktwilio/assertcall.go | 105 ++++++ devtools/mocktwilio/assertdev.go | 6 + devtools/mocktwilio/assertsms.go | 104 ++++++ devtools/mocktwilio/call.go | 42 +++ devtools/mocktwilio/errors.go | 22 ++ devtools/mocktwilio/genids.go | 21 ++ devtools/mocktwilio/handlemessagestatus.go | 39 +++ devtools/mocktwilio/handlenewmessage.go | 114 +++++++ devtools/mocktwilio/message.go | 42 +++ devtools/mocktwilio/phoneassertions.go | 76 +++++ devtools/mocktwilio/sendmessage.go | 69 ++++ devtools/mocktwilio/server.go | 367 ++++++++++---------- devtools/mocktwilio/signature.go | 38 +++ devtools/mocktwilio/sms.go | 341 ++++++------------- devtools/mocktwilio/strings.go | 21 ++ devtools/mocktwilio/voicecall.go | 369 --------------------- 17 files changed, 1126 insertions(+), 795 deletions(-) create mode 100644 devtools/mocktwilio/assert.go create mode 100644 devtools/mocktwilio/assertcall.go create mode 100644 devtools/mocktwilio/assertdev.go create mode 100644 devtools/mocktwilio/assertsms.go create mode 100644 devtools/mocktwilio/call.go create mode 100644 devtools/mocktwilio/errors.go create mode 100644 devtools/mocktwilio/genids.go create mode 100644 devtools/mocktwilio/handlemessagestatus.go create mode 100644 devtools/mocktwilio/handlenewmessage.go create mode 100644 devtools/mocktwilio/message.go create mode 100644 devtools/mocktwilio/phoneassertions.go create mode 100644 devtools/mocktwilio/sendmessage.go create mode 100644 devtools/mocktwilio/signature.go create mode 100644 devtools/mocktwilio/strings.go delete mode 100644 devtools/mocktwilio/voicecall.go diff --git a/devtools/mocktwilio/assert.go b/devtools/mocktwilio/assert.go new file mode 100644 index 0000000000..aa7d24689b --- /dev/null +++ b/devtools/mocktwilio/assert.go @@ -0,0 +1,145 @@ +package mocktwilio + +import ( + "context" + "testing" + "time" +) + +type AssertConfig struct { + ServerAPI + Timeout time.Duration + AppPhoneNumber string + + // RefreshFunc will be called before waiting for new messages or calls to arrive. + // + // It is useful for testing purposes to ensure pending messages/calls are sent from the application. + // + // Implementations should not return until requests to mocktwilio are complete. + RefreshFunc func() +} + +type ServerAPI interface { + SendMessage(ctx context.Context, from, to, body string) error + Messages() <-chan Message + Calls() <-chan Call +} + +func NewAssertions(t *testing.T, cfg AssertConfig) PhoneAssertions { + return &assert{ + t: t, + AssertConfig: cfg, + } +} + +type assert struct { + t *testing.T + AssertConfig + + messages []Message + calls []Call + + ignoreSMS []assertIgnore + ignoreCalls []assertIgnore +} + +type assertIgnore struct { + number string + keywords []string +} + +type texter interface { + To() string + Text() string +} +type answerer interface { + Answer(context.Context) error +} + +func (a *assert) matchMessage(destNumber string, keywords []string, t texter) bool { + a.t.Helper() + if t.To() != destNumber { + return false + } + + if ans, ok := t.(answerer); ok { + ctx, cancel := context.WithTimeout(context.Background(), a.Timeout) + defer cancel() + + err := ans.Answer(ctx) + if err != nil { + a.t.Fatalf("mocktwilio: error answering call to %s: %v", t.To(), err) + } + } + + return containsAll(t.Text(), keywords) +} + +func (a *assert) refresh() { + if a.RefreshFunc == nil { + return + } + + a.RefreshFunc() +} + +func (a *assert) Device(number string) PhoneDevice { + return &assertDev{a, number} +} + +func (a *assert) WaitAndAssert() { + a.t.Helper() + + // flush any remaining application messages + a.refresh() + +drainMessages: + for { + select { + case msg := <-a.Messages(): + a.messages = append(a.messages, msg) + default: + break drainMessages + } + } + +drainCalls: + for { + select { + case call := <-a.Calls(): + a.calls = append(a.calls, call) + default: + break drainCalls + } + } + + var hasFailure bool + +checkMessages: + for _, msg := range a.messages { + for _, ignore := range a.ignoreSMS { + if a.matchMessage(ignore.number, ignore.keywords, msg) { + continue checkMessages + } + + hasFailure = true + a.t.Errorf("mocktwilio: unexpected SMS to %s: %s", msg.To(), msg.Text()) + } + } + +checkCalls: + for _, call := range a.calls { + for _, ignore := range a.ignoreCalls { + if a.matchMessage(ignore.number, ignore.keywords, call) { + continue checkCalls + } + + hasFailure = true + a.t.Errorf("mocktwilio: unexpected call to %s: %s", call.To(), call.Text()) + } + } + + if hasFailure { + a.t.FailNow() + } +} diff --git a/devtools/mocktwilio/assertcall.go b/devtools/mocktwilio/assertcall.go new file mode 100644 index 0000000000..2662b39b47 --- /dev/null +++ b/devtools/mocktwilio/assertcall.go @@ -0,0 +1,105 @@ +package mocktwilio + +import ( + "context" + "time" +) + +type assertCall struct { + *assertDev + Call +} + +func (call *assertCall) Hangup() { + call.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), call.Timeout) + defer cancel() + + err := call.End(ctx, CallCompleted) + if err != nil { + call.t.Fatalf("mocktwilio: error ending call to %s: %v", call.To(), err) + } +} + +func (call *assertCall) ThenPress(digits string) ExpectedCall { + call.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), call.Timeout) + defer cancel() + err := call.Press(ctx, digits) + if err != nil { + call.t.Fatalf("mocktwilio: error pressing digits %s to %s: %v", digits, call.To(), err) + } + + return call +} + +func (call *assertCall) ThenExpect(keywords ...string) ExpectedCall { + call.t.Helper() + + if !containsAll(call.Text(), keywords) { + call.t.Fatalf("mocktwilio: expected call to %s to contain keywords: %v, but got: %s", call.To(), keywords, call.Text()) + } + + return call +} + +func (dev *assertDev) ExpectVoice(keywords ...string) ExpectedCall { + dev.t.Helper() + + return &assertCall{ + assertDev: dev, + Call: dev.getVoice(false, keywords), + } +} + +func (dev *assertDev) RejectVoice(keywords ...string) { + dev.t.Helper() + + call := dev.getVoice(false, keywords) + ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) + defer cancel() + + err := call.End(ctx, CallFailed) + if err != nil { + dev.t.Fatalf("mocktwilio: error ending call to %s: %v", call.To(), err) + } +} + +func (dev *assertDev) IgnoreUnexpectedVoice(keywords ...string) { + dev.ignoreCalls = append(dev.ignoreCalls, assertIgnore{number: dev.number, keywords: keywords}) +} + +func (dev *assertDev) getVoice(prev bool, keywords []string) Call { + dev.t.Helper() + + if prev { + for _, call := range dev.calls { + if !dev.matchMessage(dev.number, keywords, call) { + continue + } + + return call + } + } + + dev.refresh() + + t := time.NewTimer(dev.Timeout) + defer t.Stop() + + for { + select { + case <-t.C: + dev.t.Fatalf("mocktwilio: timeout after %s waiting for a voice call to %s with keywords: %v", dev.Timeout, dev.number, keywords) + case call := <-dev.Calls(): + if !dev.matchMessage(dev.number, keywords, call) { + dev.calls = append(dev.calls, call) + continue + } + + return call + } + } +} diff --git a/devtools/mocktwilio/assertdev.go b/devtools/mocktwilio/assertdev.go new file mode 100644 index 0000000000..110a6d84be --- /dev/null +++ b/devtools/mocktwilio/assertdev.go @@ -0,0 +1,6 @@ +package mocktwilio + +type assertDev struct { + *assert + number string +} diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go new file mode 100644 index 0000000000..024cee43bb --- /dev/null +++ b/devtools/mocktwilio/assertsms.go @@ -0,0 +1,104 @@ +package mocktwilio + +import ( + "context" + "time" +) + +type assertSMS struct { + *assertDev + Message +} + +func (sms *assertSMS) ThenExpect(keywords ...string) ExpectedSMS { + sms.t.Helper() + return sms._ExpectSMS(false, MessageDelivered, keywords...) +} + +func (sms *assertSMS) ThenReply(body string) SMSReply { + sms.SendSMS(body) + return sms +} + +func (dev *assertDev) SendSMS(body string) { + dev.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) + defer cancel() + + err := dev.SendMessage(ctx, dev.number, dev.AppPhoneNumber, body) + if err != nil { + dev.t.Fatalf("mocktwilio: send SMS %s to %s: %v", body, dev.number, err) + } +} + +func (dev *assertDev) ExpectSMS(keywords ...string) ExpectedSMS { + dev.t.Helper() + return dev._ExpectSMS(true, MessageDelivered, keywords...) +} + +func (dev *assertDev) RejectSMS(keywords ...string) { + dev.t.Helper() + dev._ExpectSMS(true, MessageFailed, keywords...) +} + +func (dev *assertDev) ThenExpect(keywords ...string) ExpectedSMS { + dev.t.Helper() + keywords = toLowerSlice(keywords) + + return dev._ExpectSMS(false, MessageDelivered, keywords...) +} + +func (dev *assertDev) IgnoreUnexpectedSMS(keywords ...string) { + dev.ignoreSMS = append(dev.ignoreSMS, assertIgnore{number: dev.number, keywords: keywords}) +} + +func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords ...string) *assertSMS { + dev.t.Helper() + + keywords = toLowerSlice(keywords) + if prev { + for _, msg := range dev.messages { + if !dev.matchMessage(dev.number, keywords, msg) { + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) + defer cancel() + + err := msg.SetStatus(ctx, status) + if err != nil { + dev.t.Fatalf("mocktwilio: error setting SMS status %s to %s: %v", status, msg.To(), err) + } + + return &assertSMS{assertDev: dev, Message: msg} + } + } + + dev.refresh() + + t := time.NewTimer(dev.Timeout) + defer t.Stop() + + for { + select { + case <-t.C: + dev.t.Fatalf("mocktwilio: timeout after %s waiting for an SMS to %s with keywords: %v", dev.Timeout, dev.number, keywords) + case msg := <-dev.Messages(): + if !dev.matchMessage(dev.number, keywords, msg) { + dev.messages = append(dev.messages, msg) + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) + defer cancel() + + err := msg.SetStatus(ctx, status) + if err != nil { + dev.t.Fatalf("mocktwilio: error setting SMS status %s to %s: %v", status, msg.To(), err) + } + + return &assertSMS{assertDev: dev, Message: msg} + } + } +} diff --git a/devtools/mocktwilio/call.go b/devtools/mocktwilio/call.go new file mode 100644 index 0000000000..c4a7dfea89 --- /dev/null +++ b/devtools/mocktwilio/call.go @@ -0,0 +1,42 @@ +package mocktwilio + +import "context" + +type Call interface { + ID() string + + From() string + To() string + + // Text will return the last message returned by the application. It is empty until Answer is called. + Text() string + + Answer(context.Context) error + + // Press will simulate a press of the specified key. + // + // It does nothing if Answer has not been called or + // the call has ended. + Press(context.Context, string) error + + End(context.Context, FinalCallStatus) error +} + +type FinalCallStatus string + +const ( + CallCompleted FinalCallStatus = "completed" + CallFailed FinalCallStatus = "failed" + CallBusy FinalCallStatus = "busy" + CallNoAnswer FinalCallStatus = "no-answer" + CallCanceled FinalCallStatus = "canceled" +) + +func (srv *Server) Calls() <-chan Call { + return nil +} + +// StartCall will start a new voice call. +func (srv *Server) StartCall(ctx context.Context, from, to string) error { + return nil +} diff --git a/devtools/mocktwilio/errors.go b/devtools/mocktwilio/errors.go new file mode 100644 index 0000000000..9df2e95211 --- /dev/null +++ b/devtools/mocktwilio/errors.go @@ -0,0 +1,22 @@ +package mocktwilio + +import ( + "encoding/json" + "net/http" + "strconv" +) + +type twError struct { + Status int `json:"status"` + Code int `json:"code"` + Message string `json:"message"` + Info string `json:"more_info"` +} + +func respondErr(w http.ResponseWriter, err twError) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(err.Status) + + err.Info = "https://www.twilio.com/docs/errors/" + strconv.Itoa(err.Code) + json.NewEncoder(w).Encode(err) +} diff --git a/devtools/mocktwilio/genids.go b/devtools/mocktwilio/genids.go new file mode 100644 index 0000000000..0cff0caa8b --- /dev/null +++ b/devtools/mocktwilio/genids.go @@ -0,0 +1,21 @@ +package mocktwilio + +import ( + "fmt" + "sync/atomic" +) + +var ( + msgSvcID uint64 + phoneNum uint64 +) + +// NewMsgServiceID is a convenience method that returns a new unique messaging service ID. +func NewMsgServiceID() string { return fmt.Sprintf("MG%032d", atomic.AddUint64(&msgSvcID, 1)) } + +// NewPhoneNumber is a convenience method that returns a new unique phone number. +func NewPhoneNumber() string { + id := atomic.AddUint64(&phoneNum, 1) + + return fmt.Sprintf("+1%d555%04d", 201+id/10000, id%10000) +} diff --git a/devtools/mocktwilio/handlemessagestatus.go b/devtools/mocktwilio/handlemessagestatus.go new file mode 100644 index 0000000000..40280c6cd0 --- /dev/null +++ b/devtools/mocktwilio/handlemessagestatus.go @@ -0,0 +1,39 @@ +package mocktwilio + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// HandleMessageStatus handles GET requests to /2010-04-01/Accounts//Messages/.json +func (srv *Server) HandleMessageStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + respondErr(w, twError{ + Status: 405, + Code: 20004, + Message: "Method not allowed", + }) + return + } + + id := strings.TrimPrefix(r.URL.Path, srv.basePath()+"/Messages/") + id = strings.TrimSuffix(id, ".json") + + db := <-srv.smsDB + s := db[id] + srv.smsDB <- db + + if s == nil { + respondErr(w, twError{ + Status: 404, + Message: fmt.Sprintf("The requested resource %s was not found", r.URL.String()), + Code: 20404, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s) +} diff --git a/devtools/mocktwilio/handlenewmessage.go b/devtools/mocktwilio/handlenewmessage.go new file mode 100644 index 0000000000..365effd955 --- /dev/null +++ b/devtools/mocktwilio/handlenewmessage.go @@ -0,0 +1,114 @@ +package mocktwilio + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +// HandleNewMessage handles POST requests to /Accounts//Messages.json +func (srv *Server) HandleNewMessage(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + respondErr(w, twError{ + Status: 405, + Code: 20004, + Message: "Method not allowed", + }) + return + } + + s := srv.newSMS() + s.Direction = "outbound-api" + s.To = r.FormValue("To") + s.From = r.FormValue("From") + s.Body = r.FormValue("Body") + s.MsgSID = r.FormValue("MessagingServiceSid") + s.StatusURL = r.FormValue("StatusCallback") + + if s.Body == "" { + respondErr(w, twError{ + Status: 400, + Code: 21602, + Message: "Message body is required.", + }) + return + } + + if s.To == "" { + respondErr(w, twError{ + Status: 400, + Code: 21604, + Message: "A 'To' phone number is required.", + }) + return + } + + if s.StatusURL != "" { + u, err := url.Parse(s.StatusURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + respondErr(w, twError{ + Status: 400, + Code: 21609, + Message: fmt.Sprintf("The StatusCallback URL %s is not a valid URL.", s.StatusURL), + }) + return + } + } + + if strings.HasPrefix(s.From, "MG") && s.MsgSID == "" { + s.MsgSID = s.ID + } + + if s.MsgSID != "" { + if len(srv.msgSvc[s.MsgSID]) == 0 { + respondErr(w, twError{ + Status: 404, + Message: fmt.Sprintf("The requested resource %s was not found", r.URL.String()), + Code: 20404, + }) + return + } + + // API says you can't use both from and msg SID, but actual + // implementation allows it and uses msg SID if present. + s.From = "" + s.Status = "accepted" + } else { + if s.From == "" { + respondErr(w, twError{ + Status: 400, + Message: "A 'From' phone number is required.", + Code: 21603, + }) + return + } + + if srv.numbers[s.From] == nil { + respondErr(w, twError{ + Status: 400, + Message: fmt.Sprintf("The From phone number %s is not a valid, SMS-capable inbound phone number or short code for your account.", s.From), + Code: 21606, + }) + return + } + s.Status = "queued" + } + + // Note: There is an inherent race condition where the first status update can be fired + // before the original request returns, from the application's perspective, since there's + // no way on the Twilio side to know if the application is ready for a status update. + + // marshal the return value before any status changes to ensure consistent return value + data, err := json.Marshal(s) + if err != nil { + panic(err) + } + + srv.outboundSMSCh <- s + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write(data) +} diff --git a/devtools/mocktwilio/message.go b/devtools/mocktwilio/message.go new file mode 100644 index 0000000000..3d5524587b --- /dev/null +++ b/devtools/mocktwilio/message.go @@ -0,0 +1,42 @@ +package mocktwilio + +import ( + "context" +) + +type Message interface { + ID() string + + To() string + From() string + Text() string + + // SetStatus will set the final status of the message. + SetStatus(context.Context, FinalMessageStatus) error +} + +type FinalMessageStatus string + +const ( + MessageSent FinalMessageStatus = "sent" + MessageDelivered FinalMessageStatus = "delivered" + MessageUndelivered FinalMessageStatus = "undelivered" + MessageFailed FinalMessageStatus = "failed" + MessageReceived FinalMessageStatus = "received" +) + +// Messages returns a channel of outbound messages. +func (srv *Server) Messages() <-chan Message { return srv.messagesCh } + +type message struct { + *sms +} + +func (msg *message) ID() string { return msg.sms.ID } +func (msg *message) To() string { return msg.sms.To } +func (msg *message) From() string { return msg.sms.From } +func (msg *message) Text() string { return msg.sms.Body } + +func (msg *message) SetStatus(ctx context.Context, status FinalMessageStatus) error { + return msg.setFinalStatus(ctx, status, 0) +} diff --git a/devtools/mocktwilio/phoneassertions.go b/devtools/mocktwilio/phoneassertions.go new file mode 100644 index 0000000000..62d1a99074 --- /dev/null +++ b/devtools/mocktwilio/phoneassertions.go @@ -0,0 +1,76 @@ +package mocktwilio + +// PhoneAssertions is used to assert voice and SMS behavior. +type PhoneAssertions interface { + // Device returns a TwilioDevice for the given number. + // + // It is safe to call multiple times for the same device. + Device(number string) PhoneDevice + + // WaitAndAssert will fail the test if there are any unexpected messages received. + WaitAndAssert() +} + +// A PhoneDevice immitates a device (i.e. a phone) for testing interactions. +type PhoneDevice interface { + // SendSMS will send a message to GoAlert from the device. + SendSMS(text string) + + // ExpectSMS will match against an SMS that matches ALL provided keywords (case-insensitive). + // Each call to ExpectSMS results in the requirement that an additional SMS is received. + ExpectSMS(keywords ...string) ExpectedSMS + + // RejectSMS will match against an SMS that matches ALL provided keywords (case-insensitive) and tell the server that delivery failed. + RejectSMS(keywords ...string) + + // ExpectVoice will match against a voice call where the spoken text matches ALL provided keywords (case-insensitive). + ExpectVoice(keywords ...string) ExpectedCall + + // RejectVoice will match against a voice call where the spoken text matches ALL provided keywords (case-insensitive) and tell the server that delivery failed. + RejectVoice(keywords ...string) + + // IgnoreUnexpectedSMS will cause any extra SMS messages (after processing ExpectSMS calls) that match + // ALL keywords (case-insensitive) to not fail the test. + IgnoreUnexpectedSMS(keywords ...string) + + // IgnoreUnexpectedVoice will cause any extra voice calls (after processing ExpectVoice) that match + // ALL keywords (case-insensitive) to not fail the test. + IgnoreUnexpectedVoice(keywords ...string) +} + +// ExpectedCall represents a phone call. +type ExpectedCall interface { + // ThenPress imitates a user entering a key on the phone. + ThenPress(digits string) ExpectedCall + + // ThenExpect asserts that the message matches ALL keywords (case-insensitive). + // + // Generally used as ThenPress().ThenExpect() + ThenExpect(keywords ...string) ExpectedCall + + // Text will return the last full spoken message as text. Separate stanzas (e.g. multiple ``) are + // separated by newline. + Text() string + + // Hangup will hangup the active call. + Hangup() +} + +// ExpectedSMS represents an SMS message. +type ExpectedSMS interface { + // ThenReply will respond with an SMS with the given text. + ThenReply(text string) SMSReply + + // Text is the text of the SMS message. + Text() string + + // From is the source number of the SMS message. + From() string +} + +// SMSReply represents a reply to a received SMS message. +type SMSReply interface { + // ThenExpect will match against an SMS that matches ALL provided keywords (case-insensitive). + // The message must be received AFTER the reply is sent or the assertion will fail. + ThenExpect(keywords ...string) ExpectedSMS +} diff --git a/devtools/mocktwilio/sendmessage.go b/devtools/mocktwilio/sendmessage.go new file mode 100644 index 0000000000..c74f0e4da1 --- /dev/null +++ b/devtools/mocktwilio/sendmessage.go @@ -0,0 +1,69 @@ +package mocktwilio + +import ( + "context" + "fmt" + "net/url" + + "github.com/ttacon/libphonenumber" +) + +// SendMessage will send a message to the specified phone number, blocking until delivery. +// +// Messages sent using this method will be treated as "inbound" messages and will not +// appear in Messages(). +func (srv *Server) SendMessage(ctx context.Context, from, to, body string) (Message, error) { + _, err := libphonenumber.Parse(from, "") + if err != nil { + return nil, fmt.Errorf("invalid from phone number: %v", err) + } + _, err = libphonenumber.Parse(to, "") + if err != nil { + return nil, fmt.Errorf("invalid to phone number: %v", err) + } + + n := srv.numbers[to] + if n == nil { + return nil, fmt.Errorf("unregistered destination number: %s", to) + } + + if n.SMSWebhookURL == "" { + return nil, fmt.Errorf("no SMS webhook URL registered for number: %s", to) + } + + s := srv.newSMS() + s.Direction = "inbound" + s.To = to + s.From = from + s.Body = body + + err = s.setFinalStatus(ctx, "received", 0) + if err != nil { + return nil, fmt.Errorf("set final status: %v", err) + } + + v := make(url.Values) + v.Set("AccountSid", srv.cfg.AccountSID) + v.Set("ApiVersion", "2010-04-01") + v.Set("Body", body) + v.Set("From", from) + // from city/country/state/zip omitted + v.Set("MessageSid", s.ID) + v.Set("NumMedia", "0") + v.Set("NumSegments", "1") + // SmsMessageSid/SmsSid omitted + v.Set("SmsStatus", s.Status) + v.Set("To", to) + // to city/country/state/zip omitted + + db := <-srv.smsDB + db[s.ID] = s + srv.smsDB <- db + + _, err = srv.post(ctx, n.SMSWebhookURL, v) + if err != nil { + return nil, err + } + + return &message{s}, nil +} diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index 15e2a3e90d..8aef07ba57 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -1,60 +1,78 @@ package mocktwilio import ( - "encoding/json" + "context" "fmt" "io" - "math/rand" "net/http" "net/url" "strings" "sync" "sync/atomic" - "time" "github.com/pkg/errors" "github.com/target/goalert/notification/twilio" - "github.com/target/goalert/validation/validate" + "github.com/ttacon/libphonenumber" ) // Config is used to configure the mock server. type Config struct { - - // The SID and token should match values given to the backend - // as the mock server will send and validate signatures. + // AccountSID is the Twilio account SID. AccountSID string - AuthToken string - // MinQueueTime determines the minimum amount of time an SMS or voice - // call will sit in the queue before being processed/delivered. - MinQueueTime time.Duration + // AuthToken is the Twilio auth token. + AuthToken string + + Numbers []Number + MsgServices []MsgService + + // If EnableAuth is true, incoming requests will need to have a valid Authorization header. + EnableAuth bool + + OnError func(context.Context, error) } -// Server implements the Twilio API for SMS and Voice calls -// via the http.Handler interface. -type Server struct { - mx sync.RWMutex - callbacks map[string]string +// Number represents a mock phone number. +type Number struct { + Number string - smsInCh chan *SMS - callInCh chan *VoiceCall + VoiceWebhookURL string + SMSWebhookURL string +} + +// MsgService allows configuring a mock messaging service that can rotate between available numbers. +type MsgService struct { + // SID is the messaging service ID, it must start with 'MG'. + SID string - smsCh chan *SMS - callCh chan *VoiceCall + Numbers []string - errs chan error + // SMSWebhookURL is the URL to which SMS messages will be sent. + // + // It takes precedence over the SMSWebhookURL field in the Config.Numbers field + // for all numbers in the service. + SMSWebhookURL string +} +// Server implements the Twilio API for SMS and Voice calls +// via the http.Handler interface. +type Server struct { cfg Config - messages map[string]*SMS - calls map[string]*VoiceCall - msgSvc map[string][]string + smsDB chan map[string]*sms + messagesCh chan Message + outboundSMSCh chan *sms + + numbers map[string]*Number + msgSvc map[string][]*Number mux *http.ServeMux - shutdown chan struct{} + once sync.Once + shutdown chan struct{} + shutdownDone chan struct{} - sidSeq uint64 + id uint64 workers sync.WaitGroup @@ -62,202 +80,195 @@ type Server struct { carrierInfoMx sync.Mutex } -// NewServer creates a new Server. -func NewServer(cfg Config) *Server { - if cfg.MinQueueTime == 0 { - cfg.MinQueueTime = 100 * time.Millisecond +func validateURL(s string) error { + u, err := url.Parse(s) + if err != nil { + return err } - s := &Server{ - cfg: cfg, - callbacks: make(map[string]string), - mux: http.NewServeMux(), - messages: make(map[string]*SMS), - calls: make(map[string]*VoiceCall), - msgSvc: make(map[string][]string), - smsCh: make(chan *SMS), - smsInCh: make(chan *SMS), - callCh: make(chan *VoiceCall), - callInCh: make(chan *VoiceCall), - errs: make(chan error, 10000), - shutdown: make(chan struct{}), - carrierInfo: make(map[string]twilio.CarrierInfo), + if u.Scheme == "" { + return errors.Errorf("invalid URL (missing scheme): %s", s) } - base := "/Accounts/" + cfg.AccountSID - - s.mux.HandleFunc(base+"/Calls.json", s.serveNewCall) - s.mux.HandleFunc(base+"/Messages.json", s.serveNewMessage) - s.mux.HandleFunc(base+"/Calls/", s.serveCallStatus) - s.mux.HandleFunc(base+"/Messages/", s.serveMessageStatus) - s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) + return nil +} - s.workers.Add(1) - go s.loop() +// NewServer creates a new Server. +func NewServer(cfg Config) (*Server, error) { + if cfg.AccountSID == "" { + return nil, errors.New("AccountSID is required") + } - return s -} + srv := &Server{ + cfg: cfg, + msgSvc: make(map[string][]*Number), + numbers: make(map[string]*Number), + mux: http.NewServeMux(), -// Errors returns a channel that gets fed all errors when calling -// the backend. -func (s *Server) Errors() chan error { - return s.errs -} + smsDB: make(chan map[string]*sms, 1), + messagesCh: make(chan Message), + outboundSMSCh: make(chan *sms), -func (s *Server) post(url string, v url.Values) ([]byte, error) { - req, err := http.NewRequest("POST", url, strings.NewReader(v.Encode())) - if err != nil { - return nil, err + shutdown: make(chan struct{}), + shutdownDone: make(chan struct{}), } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("X-Twilio-Signature", string(twilio.Signature(s.cfg.AuthToken, url, v))) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - if resp.StatusCode/100 != 2 { - return nil, errors.Errorf("non-2xx response: %s", resp.Status) - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + for _, n := range cfg.Numbers { + _, err := libphonenumber.Parse(n.Number, "") + if err != nil { + return nil, fmt.Errorf("invalid phone number %s: %v", n.Number, err) + } + if n.SMSWebhookURL != "" { + err = validateURL(n.SMSWebhookURL) + if err != nil { + return nil, err + } + } + if n.VoiceWebhookURL != "" { + err = validateURL(n.VoiceWebhookURL) + if err != nil { + return nil, err + } + } + + // point to copy + _n := n + srv.numbers[n.Number] = &_n } + for _, m := range cfg.MsgServices { + if !strings.HasPrefix(m.SID, "MG") { + return nil, fmt.Errorf("invalid MsgService SID %s", m.SID) + } - if len(data) == 0 && resp.StatusCode != 204 { - return nil, errors.Errorf("non-204 response on empty body: %s", resp.Status) + if m.SMSWebhookURL != "" { + err := validateURL(m.SMSWebhookURL) + if err != nil { + return nil, err + } + } + + for _, nStr := range m.Numbers { + _, err := libphonenumber.Parse(nStr, "") + if err != nil { + return nil, fmt.Errorf("invalid phone number %s: %v", nStr, err) + } + + n := srv.numbers[nStr] + if n == nil { + n = &Number{Number: nStr} + srv.numbers[nStr] = n + } + + if m.SMSWebhookURL == "" { + continue + } + + n.SMSWebhookURL = m.SMSWebhookURL + } } - return data, nil + srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) + srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) + // s.mux.HandleFunc(base+"/Calls.json", s.serveNewCall) + // s.mux.HandleFunc(base+"/Calls/", s.serveCallStatus) + // s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) + + go srv.loop() + + return srv, nil } -func (s *Server) id(prefix string) string { - return fmt.Sprintf("%s%032d", prefix, atomic.AddUint64(&s.sidSeq, 1)) +func (srv *Server) basePath() string { + return "/2010-04-01/Accounts/" + srv.cfg.AccountSID } -// Close will shutdown the server loop. -func (s *Server) Close() error { - close(s.shutdown) - s.workers.Wait() - return nil +func (srv *Server) nextID(prefix string) string { + return fmt.Sprintf("%s%032d", prefix, atomic.AddUint64(&srv.id, 1)) } -// wait will wait the specified amount of time, but return -// true if aborted due to shutdown. -func (s *Server) wait(dur time.Duration) bool { - t := time.NewTimer(dur) - defer t.Stop() - select { - case <-t.C: - return false - case <-s.shutdown: - return true +func (srv *Server) logErr(ctx context.Context, err error) { + if srv.cfg.OnError == nil { + return } + + srv.cfg.OnError(ctx, err) +} + +func (srv *Server) Close() error { + srv.once.Do(func() { + close(srv.shutdown) + }) + + <-srv.shutdownDone + return nil } -func (s *Server) loop() { - defer s.workers.Done() +func (srv *Server) loop() { + var wg sync.WaitGroup + + defer close(srv.shutdownDone) + defer close(srv.messagesCh) + defer wg.Wait() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() for { select { - case <-s.shutdown: - return - default: - } + case <-srv.shutdown: - select { - case <-s.shutdown: return - case sms := <-s.smsInCh: - s.workers.Add(1) - go sms.process() - case vc := <-s.callInCh: - s.workers.Add(1) - go vc.process() + case sms := <-srv.outboundSMSCh: + wg.Add(1) + go func() { + sms.lifecycle(ctx) + wg.Done() + }() } } } -func apiError(status int, w http.ResponseWriter, e *twilio.Exception) { - w.WriteHeader(status) - err := json.NewEncoder(w).Encode(e) +func (s *Server) post(ctx context.Context, url string, v url.Values) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(v.Encode())) if err != nil { - panic(err) + return nil, err } -} - -// ServeHTTP implements the http.Handler interface for serving [mock] API requests. -func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { - s.mux.ServeHTTP(w, req) -} - -// SetCarrierInfo will set/update the carrier info (used for the Lookup API) for the given number. -func (s *Server) SetCarrierInfo(number string, info twilio.CarrierInfo) { - s.carrierInfoMx.Lock() - defer s.carrierInfoMx.Unlock() - s.carrierInfo[number] = info -} - -// getFromNumber will return a random number from the messaging service if ID is a -// messaging SID, or the value itself otherwise. -func (s *Server) getFromNumber(id string) string { - if !strings.HasPrefix(id, "MG") { - return id + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Twilio-Signature", Signature(s.cfg.AuthToken, url, v)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err } - - s.mx.Lock() - defer s.mx.Unlock() - - // select a random number from the message service - if len(s.msgSvc[id]) == 0 { - return "" + if resp.StatusCode/100 != 2 { + return nil, errors.Errorf("non-2xx response: %s", resp.Status) } + defer resp.Body.Close() - return s.msgSvc[id][rand.Intn(len(s.msgSvc[id]))] -} - -// NewMessagingService registers a new Messaging SID for the given numbers. -func (s *Server) NewMessagingService(url string, numbers ...string) (string, error) { - err := validate.URL("URL", url) - for i, n := range numbers { - err = validate.Many(err, validate.Phone(fmt.Sprintf("Number[%d]", i), n)) - } + data, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return nil, err } - svcID := s.id("MG") - s.mx.Lock() - defer s.mx.Unlock() - for _, num := range numbers { - s.callbacks["SMS:"+num] = url + if len(data) == 0 && resp.StatusCode != 204 { + return nil, errors.Errorf("non-204 response on empty body: %s", resp.Status) } - s.msgSvc[svcID] = numbers - return svcID, nil + return data, nil } -// RegisterSMSCallback will set/update a callback URL for SMS calls made to the given number. -func (s *Server) RegisterSMSCallback(number, url string) error { - err := validate.URL("URL", url) - if err != nil { - return err +// ServeHTTP implements the http.Handler interface for serving [mock] API requests. +func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if s.cfg.EnableAuth { + user, pass, ok := req.BasicAuth() + if !ok || user != s.cfg.AccountSID || pass != s.cfg.AuthToken { + respondErr(w, twError{ + Status: 401, + Code: 20003, + Message: "Authenticate", + }) + return + } } - s.mx.Lock() - defer s.mx.Unlock() - s.callbacks["SMS:"+number] = url - return nil -} -// RegisterVoiceCallback will set/update a callback URL for voice calls made to the given number. -func (s *Server) RegisterVoiceCallback(number, url string) error { - err := validate.URL("URL", url) - if err != nil { - return err - } - s.mx.Lock() - defer s.mx.Unlock() - s.callbacks["VOICE:"+number] = url - return nil + s.mux.ServeHTTP(w, req) } diff --git a/devtools/mocktwilio/signature.go b/devtools/mocktwilio/signature.go new file mode 100644 index 0000000000..9f7d22e6b6 --- /dev/null +++ b/devtools/mocktwilio/signature.go @@ -0,0 +1,38 @@ +package mocktwilio + +import ( + "bytes" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "io" + "net/url" + "sort" +) + +// Signature will calculate the raw signature for a request from Twilio. +// https://www.twilio.com/docs/api/security#validating-requests +func Signature(authToken, url string, fields url.Values) string { + buf := new(bytes.Buffer) + buf.WriteString(url) + + fieldNames := make(sort.StringSlice, 0, len(fields)) + for name := range fields { + fieldNames = append(fieldNames, name) + } + fieldNames.Sort() + + for _, fieldName := range fieldNames { + buf.WriteString(fieldName + fields.Get(fieldName)) + } + + hash := hmac.New(sha1.New, []byte(authToken)) + io.Copy(hash, buf) + + buf.Reset() + enc := base64.NewEncoder(base64.StdEncoding, buf) + enc.Write(hash.Sum(nil)) + enc.Close() + + return buf.String() +} diff --git a/devtools/mocktwilio/sms.go b/devtools/mocktwilio/sms.go index 5b1474c658..543456bc22 100644 --- a/devtools/mocktwilio/sms.go +++ b/devtools/mocktwilio/sms.go @@ -1,291 +1,136 @@ package mocktwilio import ( + "context" "encoding/json" - "errors" "fmt" - "net/http" + "math/rand" "net/url" - "path" - "strings" "sync" "time" - - "github.com/target/goalert/notification/twilio" - "github.com/target/goalert/validation/validate" ) -// SMS represents an SMS message. -type SMS struct { - s *Server - msg twilio.Message - body string - statusURL string - destURL string - start time.Time - mx sync.Mutex - - acceptCh chan bool - doneCh chan struct{} -} - -func (s *Server) sendSMS(fromValue, to, body, statusURL, destURL string) (*SMS, error) { - fromNumber := s.getFromNumber(fromValue) - if statusURL != "" { - err := validate.URL("StatusCallback", statusURL) +type sms struct { + Body string `json:"body"` + CreatedAt time.Time `json:"date_created"` + SentAt *time.Time `json:"date_sent,omitempty"` + UpdatedAt time.Time `json:"date_updated"` + Direction string `json:"direction"` + MsgSID string `json:"messaging_service_sid,omitempty"` + ID string `json:"sid"` + Status string `json:"status"` + To string `json:"to"` + From string `json:"from"` + + StatusURL string `json:"-"` + + srv *Server + mx sync.Mutex + setFinal chan FinalMessageStatus + final sync.Once +} + +func (srv *Server) newSMS() *sms { + n := time.Now() + return &sms{ + setFinal: make(chan FinalMessageStatus, 1), + CreatedAt: n, + UpdatedAt: n, + srv: srv, + ID: srv.nextID("SM"), + } +} + +func (s *sms) lifecycle(ctx context.Context) { + if s.MsgSID != "" { + nums := s.srv.msgSvc[s.MsgSID] + idx := rand.Intn(len(nums)) + newFrom := nums[idx] + err := s.setSendStatus(ctx, "queued", newFrom.Number) if err != nil { - return nil, twilio.Exception{ - Code: 11100, - Message: err.Error(), - } - } - s.mx.RLock() - _, hasCallback := s.callbacks["SMS:"+fromNumber] - s.mx.RUnlock() - - if !hasCallback { - return nil, twilio.Exception{ - Code: 21606, - Message: `The "From" phone number provided is not a valid, SMS-capable inbound phone number for your account.`, - } + s.srv.logErr(ctx, err) } } - if destURL != "" { - err := validate.URL("Callback", destURL) - if err != nil { - return nil, twilio.Exception{ - Code: 11100, - Message: err.Error(), - } - } - } - - sms := &SMS{ - s: s, - msg: twilio.Message{ - To: to, - Status: twilio.MessageStatusAccepted, - SID: s.id("SM"), - }, - statusURL: statusURL, - destURL: destURL, - start: time.Now(), - body: body, - acceptCh: make(chan bool, 1), - doneCh: make(chan struct{}), - } - if strings.HasPrefix(fromValue, "MG") { - sms.msg.MessagingServiceSID = fromValue - } else { - sms.msg.From = fromValue - } - - s.mx.Lock() - s.messages[sms.msg.SID] = sms - s.mx.Unlock() - - s.smsInCh <- sms - - return sms, nil -} - -func (s *Server) serveNewMessage(w http.ResponseWriter, req *http.Request) { - if req.Method != "POST" { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return + err := s.setSendStatus(ctx, "sending", "") + if err != nil { + s.srv.logErr(ctx, err) } - sms, err := s.sendSMS(req.FormValue("From"), req.FormValue("To"), req.FormValue("Body"), req.FormValue("StatusCallback"), "") - - if e := (twilio.Exception{}); errors.As(err, &e) { - apiError(400, w, &e) - return - } - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + n := s.srv.numbers[s.To] + if n == nil { + select { + case <-ctx.Done(): + case s.srv.messagesCh <- &message{s}: + } return } - data, err := json.Marshal(sms.cloneMessage()) + // destined for app + _, err = s.srv.SendMessage(ctx, s.From, s.To, s.Body) if err != nil { - panic(err) + s.srv.logErr(ctx, err) + err = s.setFinalStatus(ctx, "undelivered", 30006) + } else { + err = s.setFinalStatus(ctx, "delivered", 0) } - - w.WriteHeader(201) - _, err = w.Write(data) if err != nil { - panic(err) + s.srv.logErr(ctx, err) } } -func (s *Server) serveMessageStatus(w http.ResponseWriter, req *http.Request) { - id := strings.TrimSuffix(path.Base(req.URL.Path), ".json") - sms := s.sms(id) - if sms == nil { - http.NotFound(w, req) - return - } - - err := json.NewEncoder(w).Encode(sms.cloneMessage()) - if err != nil { - panic(err) +func (s *sms) setSendStatus(ctx context.Context, status, updateFrom string) error { + s.mx.Lock() + if updateFrom != "" { + s.From = updateFrom } -} - -func (sms *SMS) updateStatus(stat twilio.MessageStatus) { - sms.mx.Lock() - sms.msg.Status = stat - switch stat { - case twilio.MessageStatusAccepted, twilio.MessageStatusQueued: - default: - if sms.msg.MessagingServiceSID == "" { - break - } - - sms.msg.From = sms.s.getFromNumber(sms.msg.MessagingServiceSID) + s.Status = status + s.UpdatedAt = time.Now() + if status == "sent" { + s.SentAt = &s.UpdatedAt } - sms.mx.Unlock() + s.mx.Unlock() - if sms.statusURL == "" { - return + if s.Direction == "inbound" { + return nil } - - // attempt post to status callback - _, err := sms.s.post(sms.statusURL, sms.values(false)) - if err != nil { - sms.s.errs <- err + if s.StatusURL == "" { + return nil } -} -func (sms *SMS) cloneMessage() *twilio.Message { - sms.mx.Lock() - defer sms.mx.Unlock() - msg := sms.msg - return &msg -} - -func (s *Server) sms(id string) *SMS { - s.mx.RLock() - defer s.mx.RUnlock() - - return s.messages[id] -} - -func (sms *SMS) values(body bool) url.Values { v := make(url.Values) - msg := sms.cloneMessage() - v.Set("MessageStatus", string(msg.Status)) - v.Set("MessageSid", msg.SID) - v.Set("To", msg.To) - v.Set("From", msg.From) - if body { - v.Set("Body", sms.body) - } - return v -} - -// SMS will return a channel that will be fed incomming SMS messages as they arrive. -func (s *Server) SMS() chan *SMS { - return s.smsCh -} - -// SendSMS will cause an SMS to be sent to the given number with the contents of body. -// -// The to parameter must match a value passed to RegisterSMSCallback or an error is returned. -func (s *Server) SendSMS(from, to, body string) error { - s.mx.RLock() - cbURL := s.callbacks["SMS:"+to] - s.mx.RUnlock() - - if cbURL == "" { - return fmt.Errorf(`unknown/unregistered desination (to) number "%s"`, to) - } - - sms, err := s.sendSMS(from, to, body, "", cbURL) + v.Set("AccountSid", s.srv.cfg.AccountSID) + v.Set("ApiVersion", "2010-04-01") + v.Set("From", s.From) + v.Set("MessageSid", s.ID) + v.Set("MessageStatus", s.Status) + // SmsSid/SmsStatus omitted + v.Set("To", s.To) + + _, err := s.srv.post(ctx, s.StatusURL, v) if err != nil { - return err + return fmt.Errorf("send status callback: %v", err) } - <-sms.doneCh - return nil } -func (sms *SMS) process() { - defer sms.s.workers.Done() - defer close(sms.doneCh) - - if sms.s.wait(sms.s.cfg.MinQueueTime) { - return - } - - sms.updateStatus(twilio.MessageStatusQueued) +func (s *sms) setFinalStatus(ctx context.Context, status FinalMessageStatus, code int) error { + var err error + s.final.Do(func() { + err = s.setSendStatus(ctx, string(status), "") + }) - if sms.s.wait(sms.s.cfg.MinQueueTime) { - return - } - - sms.updateStatus(twilio.MessageStatusSending) - - if sms.destURL != "" { - // inbound SMS - _, err := sms.s.post(sms.destURL, sms.values(true)) - if err != nil { - sms.s.errs <- err - sms.updateStatus(twilio.MessageStatusUndelivered) - } else { - sms.updateStatus(twilio.MessageStatusDelivered) - } - return - } - - select { - case <-sms.s.shutdown: - return - case sms.s.smsCh <- sms: - } - - select { - case <-sms.s.shutdown: - return - case accepted := <-sms.acceptCh: - if accepted { - sms.updateStatus(twilio.MessageStatusDelivered) - } else { - sms.updateStatus(twilio.MessageStatusFailed) - } - } + return err } -// ID will return the unique ID for this SMS. -func (sms *SMS) ID() string { - return sms.msg.SID -} - -// From returns the phone number the SMS was sent from. -func (sms *SMS) From() string { - return sms.msg.From -} - -// To returns the phone number the SMS is being sent to. -func (sms *SMS) To() string { - return sms.msg.To -} - -// Body returns the contents of the SMS message. -func (sms *SMS) Body() string { - return sms.body -} - -// Accept will cause the SMS to be marked as delivered. -func (sms *SMS) Accept() { - sms.acceptCh <- true - close(sms.acceptCh) -} +func (s *sms) MarshalJSON() ([]byte, error) { + if s == nil { + return []byte("null"), nil + } -// Reject will cause the SMS to be marked as failed. -func (sms *SMS) Reject() { - sms.acceptCh <- false - close(sms.acceptCh) + type data sms + s.mx.Lock() + defer s.mx.Unlock() + return json.Marshal((*data)(s)) } diff --git a/devtools/mocktwilio/strings.go b/devtools/mocktwilio/strings.go new file mode 100644 index 0000000000..dc55986d17 --- /dev/null +++ b/devtools/mocktwilio/strings.go @@ -0,0 +1,21 @@ +package mocktwilio + +import "strings" + +func toLowerSlice(s []string) []string { + for i, a := range s { + s[i] = strings.ToLower(a) + } + return s +} + +func containsAll(body string, vals []string) bool { + body = strings.ToLower(body) + for _, a := range toLowerSlice(vals) { + if !strings.Contains(body, a) { + return false + } + } + + return true +} diff --git a/devtools/mocktwilio/voicecall.go b/devtools/mocktwilio/voicecall.go deleted file mode 100644 index 4538055b25..0000000000 --- a/devtools/mocktwilio/voicecall.go +++ /dev/null @@ -1,369 +0,0 @@ -package mocktwilio - -import ( - "bytes" - "encoding/json" - "encoding/xml" - "fmt" - "net/http" - "net/url" - "path" - "strconv" - "strings" - "sync" - "time" - - "github.com/pkg/errors" - "github.com/target/goalert/notification/twilio" - "github.com/target/goalert/validation/validate" -) - -// VoiceCall represents a voice call session. -type VoiceCall struct { - s *Server - - mx sync.Mutex - - call twilio.Call - - acceptCh chan struct{} - rejectCh chan struct{} - - messageCh chan string - pressCh chan string - hangupCh chan struct{} - doneCh chan struct{} - - // start is used to track when the call was created (entered queue) - start time.Time - - // callStart tracks when the call was accepted - // and is used to cacluate call.CallDuration when completed. - callStart time.Time - url string - callbackURL string - lastMessage string - callbackEvents []string - hangup bool -} - -func (vc *VoiceCall) process() { - defer vc.s.workers.Done() - defer close(vc.doneCh) - - if vc.s.wait(vc.s.cfg.MinQueueTime) { - return - } - - vc.updateStatus(twilio.CallStatusInitiated) - - if vc.s.wait(vc.s.cfg.MinQueueTime) { - return - } - - vc.updateStatus(twilio.CallStatusRinging) - - var err error - vc.lastMessage, err = vc.fetchMessage("") - if err != nil { - vc.s.errs <- fmt.Errorf("fetch message: %w", err) - return - } - select { - case vc.s.callCh <- vc: - case <-vc.s.shutdown: - return - } - -waitForAccept: - for { - select { - case vc.messageCh <- vc.lastMessage: - case <-vc.acceptCh: - break waitForAccept - case <-vc.rejectCh: - vc.updateStatus(twilio.CallStatusFailed) - return - case <-vc.s.shutdown: - return - } - } - - vc.updateStatus(twilio.CallStatusInProgress) - vc.callStart = time.Now() - - for { - select { - case <-vc.rejectCh: - vc.updateStatus(twilio.CallStatusFailed) - return - case <-vc.s.shutdown: - return - case <-vc.hangupCh: - vc.updateStatus(twilio.CallStatusCompleted) - return - case vc.messageCh <- vc.lastMessage: - case digits := <-vc.pressCh: - vc.lastMessage, err = vc.fetchMessage(digits) - if err != nil { - vc.s.errs <- fmt.Errorf("fetch message: %w", err) - return - } - if vc.hangup { - vc.updateStatus(twilio.CallStatusCompleted) - return - } - } - } -} - -func (s *Server) serveCallStatus(w http.ResponseWriter, req *http.Request) { - id := strings.TrimSuffix(path.Base(req.URL.Path), ".json") - vc := s.call(id) - - if vc == nil { - http.NotFound(w, req) - return - } - err := json.NewEncoder(w).Encode(vc.cloneCall()) - if err != nil { - panic(err) - } -} -func (s *Server) call(id string) *VoiceCall { - s.mx.RLock() - defer s.mx.RUnlock() - - return s.calls[id] -} -func (s *Server) serveNewCall(w http.ResponseWriter, req *http.Request) { - if req.Method != "POST" { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - vc := VoiceCall{ - acceptCh: make(chan struct{}), - doneCh: make(chan struct{}), - rejectCh: make(chan struct{}), - messageCh: make(chan string), - pressCh: make(chan string), - hangupCh: make(chan struct{}), - } - - fromValue := req.FormValue("From") - s.mx.RLock() - _, hasCallback := s.callbacks["VOICE:"+fromValue] - s.mx.RUnlock() - if !hasCallback { - apiError(400, w, &twilio.Exception{ - Message: "Wrong from number.", - }) - return - } - - vc.s = s - vc.call.To = req.FormValue("To") - vc.call.From = fromValue - vc.call.SID = s.id("CA") - vc.call.SequenceNumber = new(int) - vc.callbackURL = req.FormValue("StatusCallback") - - err := validate.URL("StatusCallback", vc.callbackURL) - if err != nil { - apiError(400, w, &twilio.Exception{ - Code: 11100, - Message: err.Error(), - }) - return - } - vc.url = req.FormValue("Url") - err = validate.URL("StatusCallback", vc.url) - if err != nil { - apiError(400, w, &twilio.Exception{ - Code: 11100, - Message: err.Error(), - }) - return - } - - vc.callbackEvents = map[string][]string(req.Form)["StatusCallbackEvent"] - vc.callbackEvents = append(vc.callbackEvents, "completed", "failed") // always send completed and failed - vc.start = time.Now() - - vc.call.Status = twilio.CallStatusQueued - - s.mx.Lock() - s.calls[vc.call.SID] = &vc - s.mx.Unlock() - s.callInCh <- &vc - - data, err := json.Marshal(vc.cloneCall()) - if err != nil { - panic(err) - } - - w.WriteHeader(201) - _, err = w.Write(data) - if err != nil { - panic(err) - } -} - -func (vc *VoiceCall) updateStatus(stat twilio.CallStatus) { - // move to queued - vc.mx.Lock() - vc.call.Status = stat - - switch stat { - case twilio.CallStatusInProgress: - vc.callStart = time.Now() - case twilio.CallStatusCompleted: - vc.call.CallDuration = time.Since(vc.callStart) - } - *vc.call.SequenceNumber++ - vc.mx.Unlock() - - var sendEvent bool - evtName := string(stat) - if evtName == "in-progres" { - evtName = "answered" - } - for _, e := range vc.callbackEvents { - if e == evtName { - sendEvent = true - break - } - } - - if !sendEvent { - return - } - - // attempt post to status callback - _, err := vc.s.post(vc.callbackURL, vc.values("")) - if err != nil { - vc.s.errs <- errors.Wrap(err, "post to call status callback") - } -} -func (vc *VoiceCall) values(digits string) url.Values { - call := vc.cloneCall() - - v := make(url.Values) - v.Set("CallSid", call.SID) - v.Set("CallStatus", string(call.Status)) - v.Set("To", call.To) - v.Set("From", call.From) - v.Set("Direction", "outbound-api") - v.Set("SequenceNumber", strconv.Itoa(*call.SequenceNumber)) - if call.Status == twilio.CallStatusCompleted { - v.Set("CallDuration", strconv.FormatFloat(call.CallDuration.Seconds(), 'f', 1, 64)) - } - - if digits != "" { - v.Set("Digits", digits) - } - - return v -} - -// VoiceCalls will return a channel that will be fed VoiceCalls as they arrive. -func (s *Server) VoiceCalls() chan *VoiceCall { - return s.callCh -} - -func (vc *VoiceCall) cloneCall() *twilio.Call { - vc.mx.Lock() - defer vc.mx.Unlock() - - call := vc.call - return &call -} - -// Accept will allow a call to move from initiated to "in-progress". -func (vc *VoiceCall) Accept() { close(vc.acceptCh) } - -// Reject will reject a call, moving it to a "failed" state. -func (vc *VoiceCall) Reject() { close(vc.rejectCh); <-vc.doneCh } - -// Hangup will end the call, setting it's state to "completed". -func (vc *VoiceCall) Hangup() { close(vc.hangupCh); <-vc.doneCh } - -func (vc *VoiceCall) fetchMessage(digits string) (string, error) { - data, err := vc.s.post(vc.url, vc.values(digits)) - if err != nil { - return "", fmt.Errorf("post voice endpoint: %w", err) - } - type resp struct { - XMLName xml.Name `xml:"Response"` - Say []string `xml:"Say"` - Gather struct { - Action string `xml:"action,attr"` - Say []string `xml:"Say"` - } - RedirectURL string `xml:"Redirect"` - Hangup *struct{} `xml:"Hangup"` - } - var r resp - data = bytes.ReplaceAll(data, []byte(``), []byte("")) - data = bytes.ReplaceAll(data, []byte(``), []byte("")) - err = xml.Unmarshal(data, &r) - if err != nil { - return "", fmt.Errorf("unmarshal XML voice response: %w", err) - } - - s := append(r.Say, r.Gather.Say...) - if r.Gather.Action != "" { - vc.url = r.Gather.Action - } - if r.RedirectURL != "" { - // Twilio's own implementation is totally broken with relative URLs, so we assume absolute (since that's all we use as a consequence) - vc.url = r.RedirectURL - } - if r.Hangup != nil { - vc.hangup = true - } - - if r.RedirectURL != "" { - // redirect and get new message - return vc.fetchMessage("") - } - - return strings.Join(s, "\n"), nil -} - -// Status will return the current status of the call. -func (vc *VoiceCall) Status() twilio.CallStatus { - return vc.cloneCall().Status -} - -// PressDigits will re-query for a spoken message with the given digits. -func (vc *VoiceCall) PressDigits(digits string) { vc.pressCh <- digits } - -// ID returns the unique ID of this phone call. -// It is analogus to the Twilio SID of a call. -func (vc *VoiceCall) ID() string { - return vc.call.SID -} - -// To returns the destination phone number. -func (vc *VoiceCall) To() string { - return vc.call.To -} - -// From return the source phone number. -func (vc *VoiceCall) From() string { - return vc.call.From -} - -// Body will return the last spoken message of the call. -func (vc *VoiceCall) Body() string { - select { - case <-vc.doneCh: - return vc.lastMessage - case msg := <-vc.messageCh: - return msg - case <-vc.s.shutdown: - return "" - } -} From 11e5bd3c70ac29f75260b156a22bd1f99e5d12ac Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 22:53:11 -0500 Subject: [PATCH 02/46] add numbers after startup --- devtools/mocktwilio/handlenewmessage.go | 4 +- devtools/mocktwilio/sendmessage.go | 2 +- devtools/mocktwilio/server.go | 154 +++++++++++++++--------- devtools/mocktwilio/sms.go | 4 +- 4 files changed, 100 insertions(+), 64 deletions(-) diff --git a/devtools/mocktwilio/handlenewmessage.go b/devtools/mocktwilio/handlenewmessage.go index 365effd955..b4634ed80c 100644 --- a/devtools/mocktwilio/handlenewmessage.go +++ b/devtools/mocktwilio/handlenewmessage.go @@ -62,7 +62,7 @@ func (srv *Server) HandleNewMessage(w http.ResponseWriter, r *http.Request) { } if s.MsgSID != "" { - if len(srv.msgSvc[s.MsgSID]) == 0 { + if len(srv.numberSvc(s.MsgSID)) == 0 { respondErr(w, twError{ Status: 404, Message: fmt.Sprintf("The requested resource %s was not found", r.URL.String()), @@ -85,7 +85,7 @@ func (srv *Server) HandleNewMessage(w http.ResponseWriter, r *http.Request) { return } - if srv.numbers[s.From] == nil { + if srv.number(s.From) == nil { respondErr(w, twError{ Status: 400, Message: fmt.Sprintf("The From phone number %s is not a valid, SMS-capable inbound phone number or short code for your account.", s.From), diff --git a/devtools/mocktwilio/sendmessage.go b/devtools/mocktwilio/sendmessage.go index c74f0e4da1..7b86ec7410 100644 --- a/devtools/mocktwilio/sendmessage.go +++ b/devtools/mocktwilio/sendmessage.go @@ -22,7 +22,7 @@ func (srv *Server) SendMessage(ctx context.Context, from, to, body string) (Mess return nil, fmt.Errorf("invalid to phone number: %v", err) } - n := srv.numbers[to] + n := srv.number(to) if n == nil { return nil, fmt.Errorf("unregistered destination number: %s", to) } diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index 8aef07ba57..388460fcf4 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -23,9 +23,6 @@ type Config struct { // AuthToken is the Twilio auth token. AuthToken string - Numbers []Number - MsgServices []MsgService - // If EnableAuth is true, incoming requests will need to have a valid Authorization header. EnableAuth bool @@ -63,8 +60,8 @@ type Server struct { messagesCh chan Message outboundSMSCh chan *sms - numbers map[string]*Number - msgSvc map[string][]*Number + numbersDB chan map[string]*Number + msgSvcDB chan map[string][]*Number mux *http.ServeMux @@ -99,10 +96,10 @@ func NewServer(cfg Config) (*Server, error) { } srv := &Server{ - cfg: cfg, - msgSvc: make(map[string][]*Number), - numbers: make(map[string]*Number), - mux: http.NewServeMux(), + cfg: cfg, + msgSvcDB: make(chan map[string][]*Number, 1), + numbersDB: make(chan map[string]*Number, 1), + mux: http.NewServeMux(), smsDB: make(chan map[string]*sms, 1), messagesCh: make(chan Message), @@ -111,70 +108,108 @@ func NewServer(cfg Config) (*Server, error) { shutdown: make(chan struct{}), shutdownDone: make(chan struct{}), } + srv.msgSvcDB <- make(map[string][]*Number) + srv.numbersDB <- make(map[string]*Number) + + srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) + srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) + // s.mux.HandleFunc(base+"/Calls.json", s.serveNewCall) + // s.mux.HandleFunc(base+"/Calls/", s.serveCallStatus) + // s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) + + go srv.loop() + + return srv, nil +} - for _, n := range cfg.Numbers { - _, err := libphonenumber.Parse(n.Number, "") +func (srv *Server) number(s string) *Number { + db := <-srv.numbersDB + n := db[s] + srv.numbersDB <- db + return n +} + +func (srv *Server) numberSvc(id string) []*Number { + db := <-srv.msgSvcDB + nums := db[id] + srv.msgSvcDB <- db + + return nums +} + +// AddNumber adds a new number to the mock server. +func (srv *Server) AddNumber(n Number) error { + _, err := libphonenumber.Parse(n.Number, "") + if err != nil { + return fmt.Errorf("invalid phone number %s: %v", n.Number, err) + } + if n.SMSWebhookURL != "" { + err = validateURL(n.SMSWebhookURL) if err != nil { - return nil, fmt.Errorf("invalid phone number %s: %v", n.Number, err) - } - if n.SMSWebhookURL != "" { - err = validateURL(n.SMSWebhookURL) - if err != nil { - return nil, err - } - } - if n.VoiceWebhookURL != "" { - err = validateURL(n.VoiceWebhookURL) - if err != nil { - return nil, err - } + return err } - - // point to copy - _n := n - srv.numbers[n.Number] = &_n } - for _, m := range cfg.MsgServices { - if !strings.HasPrefix(m.SID, "MG") { - return nil, fmt.Errorf("invalid MsgService SID %s", m.SID) + if n.VoiceWebhookURL != "" { + err = validateURL(n.VoiceWebhookURL) + if err != nil { + return err } + } - if m.SMSWebhookURL != "" { - err := validateURL(m.SMSWebhookURL) - if err != nil { - return nil, err - } - } + db := <-srv.numbersDB + if _, ok := db[n.Number]; ok { + srv.numbersDB <- db + return fmt.Errorf("number %s already exists", n.Number) + } + db[n.Number] = &n + srv.numbersDB <- db + return nil +} - for _, nStr := range m.Numbers { - _, err := libphonenumber.Parse(nStr, "") - if err != nil { - return nil, fmt.Errorf("invalid phone number %s: %v", nStr, err) - } +// AddMsgService adds a new messaging service to the mock server. +func (srv *Server) AddMsgService(ms MsgService) error { + if !strings.HasPrefix(ms.SID, "MG") { + return fmt.Errorf("invalid MsgService SID %s", ms.SID) + } - n := srv.numbers[nStr] - if n == nil { - n = &Number{Number: nStr} - srv.numbers[nStr] = n - } + if ms.SMSWebhookURL != "" { + err := validateURL(ms.SMSWebhookURL) + if err != nil { + return err + } + } + for _, nStr := range ms.Numbers { + _, err := libphonenumber.Parse(nStr, "") + if err != nil { + return fmt.Errorf("invalid phone number %s: %v", nStr, err) + } + } - if m.SMSWebhookURL == "" { - continue - } + msDB := <-srv.msgSvcDB + if _, ok := msDB[ms.SID]; ok { + srv.msgSvcDB <- msDB + return fmt.Errorf("MsgService SID %s already exists", ms.SID) + } - n.SMSWebhookURL = m.SMSWebhookURL + numDB := <-srv.numbersDB + for _, nStr := range ms.Numbers { + n := numDB[nStr] + if n == nil { + n = &Number{Number: nStr} + numDB[nStr] = n } - } + msDB[ms.SID] = append(msDB[ms.SID], n) - srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) - srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) - // s.mux.HandleFunc(base+"/Calls.json", s.serveNewCall) - // s.mux.HandleFunc(base+"/Calls/", s.serveCallStatus) - // s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) + if ms.SMSWebhookURL == "" { + continue + } - go srv.loop() + n.SMSWebhookURL = ms.SMSWebhookURL + } + srv.numbersDB <- numDB + srv.msgSvcDB <- msDB - return srv, nil + return nil } func (srv *Server) basePath() string { @@ -193,6 +228,7 @@ func (srv *Server) logErr(ctx context.Context, err error) { srv.cfg.OnError(ctx, err) } +// Close shuts down the server. func (srv *Server) Close() error { srv.once.Do(func() { close(srv.shutdown) diff --git a/devtools/mocktwilio/sms.go b/devtools/mocktwilio/sms.go index 543456bc22..44e1139a56 100644 --- a/devtools/mocktwilio/sms.go +++ b/devtools/mocktwilio/sms.go @@ -43,7 +43,7 @@ func (srv *Server) newSMS() *sms { func (s *sms) lifecycle(ctx context.Context) { if s.MsgSID != "" { - nums := s.srv.msgSvc[s.MsgSID] + nums := s.srv.numberSvc(s.MsgSID) idx := rand.Intn(len(nums)) newFrom := nums[idx] err := s.setSendStatus(ctx, "queued", newFrom.Number) @@ -57,7 +57,7 @@ func (s *sms) lifecycle(ctx context.Context) { s.srv.logErr(ctx, err) } - n := s.srv.numbers[s.To] + n := s.srv.number(s.To) if n == nil { select { case <-ctx.Done(): From 6fc65e13382c997c5b64d7ca263360c8bb8bccf7 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 23:15:57 -0500 Subject: [PATCH 03/46] update harness to use mocktwilio package for assertions --- devtools/mocktwilio/assert.go | 17 +- devtools/mocktwilio/assertsms.go | 2 +- devtools/mocktwilio/phoneassertions.go | 5 + devtools/mocktwilio/server.go | 20 +-- smoketest/graphqlalert_test.go | 4 +- smoketest/harness/harness.go | 73 ++++---- smoketest/harness/phoneassertions.go | 78 --------- smoketest/harness/twilioassertionapi.go | 156 ------------------ smoketest/harness/twilioassertiondevice.go | 138 ---------------- smoketest/harness/twilioassertionsms.go | 20 --- smoketest/harness/twilioassertionvoicecall.go | 38 ----- smoketest/twilioonewaysms_test.go | 2 +- smoketest/twiliosmsurl_test.go | 8 +- smoketest/twiliosmsverification_test.go | 2 +- smoketest/twiliovoiceverification_test.go | 2 +- 15 files changed, 79 insertions(+), 486 deletions(-) delete mode 100644 smoketest/harness/phoneassertions.go delete mode 100644 smoketest/harness/twilioassertionapi.go delete mode 100644 smoketest/harness/twilioassertiondevice.go delete mode 100644 smoketest/harness/twilioassertionsms.go delete mode 100644 smoketest/harness/twilioassertionvoicecall.go diff --git a/devtools/mocktwilio/assert.go b/devtools/mocktwilio/assert.go index aa7d24689b..4f05b37186 100644 --- a/devtools/mocktwilio/assert.go +++ b/devtools/mocktwilio/assert.go @@ -20,20 +20,31 @@ type AssertConfig struct { } type ServerAPI interface { - SendMessage(ctx context.Context, from, to, body string) error + SendMessage(ctx context.Context, from, to, body string) (Message, error) Messages() <-chan Message Calls() <-chan Call } func NewAssertions(t *testing.T, cfg AssertConfig) PhoneAssertions { return &assert{ - t: t, - AssertConfig: cfg, + t: t, + assertBase: &assertBase{AssertConfig: cfg}, + } +} + +func (a *assert) WithT(t *testing.T) PhoneAssertions { + return &assert{ + t: t, + assertBase: a.assertBase, } } type assert struct { t *testing.T + *assertBase +} + +type assertBase struct { AssertConfig messages []Message diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go index 024cee43bb..410f254646 100644 --- a/devtools/mocktwilio/assertsms.go +++ b/devtools/mocktwilio/assertsms.go @@ -26,7 +26,7 @@ func (dev *assertDev) SendSMS(body string) { ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) defer cancel() - err := dev.SendMessage(ctx, dev.number, dev.AppPhoneNumber, body) + _, err := dev.SendMessage(ctx, dev.number, dev.AppPhoneNumber, body) if err != nil { dev.t.Fatalf("mocktwilio: send SMS %s to %s: %v", body, dev.number, err) } diff --git a/devtools/mocktwilio/phoneassertions.go b/devtools/mocktwilio/phoneassertions.go index 62d1a99074..6b11d590bc 100644 --- a/devtools/mocktwilio/phoneassertions.go +++ b/devtools/mocktwilio/phoneassertions.go @@ -1,5 +1,7 @@ package mocktwilio +import "testing" + // PhoneAssertions is used to assert voice and SMS behavior. type PhoneAssertions interface { // Device returns a TwilioDevice for the given number. @@ -9,6 +11,9 @@ type PhoneAssertions interface { // WaitAndAssert will fail the test if there are any unexpected messages received. WaitAndAssert() + + // WithT will return a new PhoneAssertions with a separate text context. + WithT(*testing.T) PhoneAssertions } // A PhoneDevice immitates a device (i.e. a phone) for testing interactions. diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index 388460fcf4..dfe8fc1ca6 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -39,8 +39,8 @@ type Number struct { // MsgService allows configuring a mock messaging service that can rotate between available numbers. type MsgService struct { - // SID is the messaging service ID, it must start with 'MG'. - SID string + // ID is the messaging service SID, it must start with 'MG'. + ID string Numbers []string @@ -90,9 +90,9 @@ func validateURL(s string) error { } // NewServer creates a new Server. -func NewServer(cfg Config) (*Server, error) { +func NewServer(cfg Config) *Server { if cfg.AccountSID == "" { - return nil, errors.New("AccountSID is required") + panic("AccountSID is required") } srv := &Server{ @@ -119,7 +119,7 @@ func NewServer(cfg Config) (*Server, error) { go srv.loop() - return srv, nil + return srv } func (srv *Server) number(s string) *Number { @@ -168,8 +168,8 @@ func (srv *Server) AddNumber(n Number) error { // AddMsgService adds a new messaging service to the mock server. func (srv *Server) AddMsgService(ms MsgService) error { - if !strings.HasPrefix(ms.SID, "MG") { - return fmt.Errorf("invalid MsgService SID %s", ms.SID) + if !strings.HasPrefix(ms.ID, "MG") { + return fmt.Errorf("invalid MsgService SID %s", ms.ID) } if ms.SMSWebhookURL != "" { @@ -186,9 +186,9 @@ func (srv *Server) AddMsgService(ms MsgService) error { } msDB := <-srv.msgSvcDB - if _, ok := msDB[ms.SID]; ok { + if _, ok := msDB[ms.ID]; ok { srv.msgSvcDB <- msDB - return fmt.Errorf("MsgService SID %s already exists", ms.SID) + return fmt.Errorf("MsgService SID %s already exists", ms.ID) } numDB := <-srv.numbersDB @@ -198,7 +198,7 @@ func (srv *Server) AddMsgService(ms MsgService) error { n = &Number{Number: nStr} numDB[nStr] = n } - msDB[ms.SID] = append(msDB[ms.SID], n) + msDB[ms.ID] = append(msDB[ms.ID], n) if ms.SMSWebhookURL == "" { continue diff --git a/smoketest/graphqlalert_test.go b/smoketest/graphqlalert_test.go index 722b3f3d4b..1f3335e05e 100644 --- a/smoketest/graphqlalert_test.go +++ b/smoketest/graphqlalert_test.go @@ -110,8 +110,8 @@ func TestGraphQLAlert(t *testing.T) { return -1 } - codeStr1 := strings.Map(digits, msg1.Body()) - codeStr2 := strings.Map(digits, msg2.Body()) + codeStr1 := strings.Map(digits, msg1.Text()) + codeStr2 := strings.Map(digits, msg2.Text()) code1, _ := strconv.Atoi(codeStr1) code2, _ := strconv.Atoi(codeStr2) diff --git a/smoketest/harness/harness.go b/smoketest/harness/harness.go index 0e66290889..555baaff7b 100644 --- a/smoketest/harness/harness.go +++ b/smoketest/harness/harness.go @@ -32,7 +32,6 @@ import ( "github.com/target/goalert/devtools/pgdump-lite" "github.com/target/goalert/devtools/pgmocktime" "github.com/target/goalert/migrate" - "github.com/target/goalert/notification/twilio" "github.com/target/goalert/permission" "github.com/target/goalert/user" "github.com/target/goalert/user/notificationrule" @@ -74,8 +73,9 @@ type Harness struct { msgSvcID string - tw *twilioAssertionAPI - twS *httptest.Server + tw mocktwilio.PhoneAssertions + mockTw *mocktwilio.Server + twS *httptest.Server cfg config.Config @@ -101,6 +101,8 @@ type Harness struct { gqlSessions map[string]string } +func (h *Harness) Twilio(t *testing.T) mocktwilio.PhoneAssertions { return h.tw.WithT(t) } + func (h *Harness) Config() config.Config { return h.cfg } @@ -169,12 +171,6 @@ func NewStoppedHarness(t *testing.T, initSQL string, sqlData interface{}, migrat t.Logf("created test database '%s': %s", name, dbURL) - twCfg := mocktwilio.Config{ - AuthToken: twilioAuthToken, - AccountSID: twilioAccountSID, - MinQueueTime: 100 * time.Millisecond, // until we have a stateless backend for answering calls - } - pgTime, err := pgmocktime.New(ctx, DBURL(name)) if err != nil { t.Fatal("create pgmocktime:", err) @@ -194,19 +190,27 @@ func NewStoppedHarness(t *testing.T, initSQL string, sqlData interface{}, migrat } h.email = newEmailServer(h) - h.tw = newTwilioAssertionAPI(func() { - h.FastForward(time.Minute) - h.Trigger() - }, func(num string) string { - id, ok := h.phoneCCG.names[num] - if !ok { - return num - } - - return fmt.Sprintf("%s/Phone(%s)", num, id) - }, mocktwilio.NewServer(twCfg), h.phoneCCG.Get("twilio")) + h.mockTw = mocktwilio.NewServer(mocktwilio.Config{ + AuthToken: twilioAuthToken, + AccountSID: twilioAccountSID, + EnableAuth: true, + OnError: func(ctx context.Context, err error) { + t.Helper() + t.Error(err) + }, + }) + h.tw = mocktwilio.NewAssertions(t, mocktwilio.AssertConfig{ + ServerAPI: h.mockTw, + Timeout: 15 * time.Second, + AppPhoneNumber: h.TwilioNumber(""), + RefreshFunc: func() { + t.Helper() + h.FastForward(time.Second) + h.Trigger() + }, + }) - h.twS = httptest.NewServer(h.tw) + h.twS = httptest.NewServer(h.mockTw) err = h.pgTime.Inject(ctx) if err != nil { @@ -563,7 +567,7 @@ func (h *Harness) Close() error { defer panic(recErr) } - h.tw.WaitAndAssert(h.t) + h.tw.WaitAndAssert() h.slack.WaitAndAssert() h.email.WaitAndAssert() @@ -582,7 +586,7 @@ func (h *Harness) Close() error { h.slackS.Close() h.twS.Close() - h.tw.Close() + h.mockTw.Close() h.dumpDB() h.pgTime.Close() @@ -602,7 +606,8 @@ func (h *Harness) Close() error { // SetCarrierName will set the carrier name for the given phone number. func (h *Harness) SetCarrierName(number, name string) { - h.tw.Server.SetCarrierInfo(number, twilio.CarrierInfo{Name: name}) + h.t.Fatal("not implemented") + // h.tw.Server.SetCarrierInfo(number, twilio.CarrierInfo{Name: name}) } // TwilioNumber will return a registered (or register if missing) Twilio number for the given ID. @@ -613,13 +618,13 @@ func (h *Harness) TwilioNumber(id string) string { } num := h.phoneCCG.Get("twilio" + id) - err := h.tw.RegisterSMSCallback(num, h.URL()+"/v1/twilio/sms/messages") - if err != nil { - h.t.Fatalf("failed to init twilio (SMS callback): %v", err) - } - err = h.tw.RegisterVoiceCallback(num, h.URL()+"/v1/twilio/voice/call") + err := h.mockTw.AddNumber(mocktwilio.Number{ + Number: num, + SMSWebhookURL: h.URL() + "/v1/twilio/sms/messages", + VoiceWebhookURL: h.URL() + "/v1/twilio/voice/call", + }) if err != nil { - h.t.Fatalf("failed to init twilio (voice callback): %v", err) + h.t.Fatalf("failed to init twilio: %v", err) } return num @@ -634,8 +639,12 @@ func (h *Harness) TwilioMessagingService() string { } defer h.mx.Unlock() - nums := []string{h.phoneCCG.Get("twilio:sid1"), h.phoneCCG.Get("twilio:sid2"), h.phoneCCG.Get("twilio:sid3")} - newID, err := h.tw.NewMessagingService(h.URL()+"/v1/twilio/sms/messages", nums...) + newID := mocktwilio.NewMsgServiceID() + err := h.mockTw.AddMsgService(mocktwilio.MsgService{ + ID: newID, + Numbers: []string{h.phoneCCG.Get("twilio:sid1"), h.phoneCCG.Get("twilio:sid2"), h.phoneCCG.Get("twilio:sid3")}, + SMSWebhookURL: h.URL() + "/v1/twilio/sms/messages", + }) if err != nil { panic(err) } diff --git a/smoketest/harness/phoneassertions.go b/smoketest/harness/phoneassertions.go deleted file mode 100644 index 9866317523..0000000000 --- a/smoketest/harness/phoneassertions.go +++ /dev/null @@ -1,78 +0,0 @@ -package harness - -// PhoneAssertions is used to assert voice and SMS behavior. -type PhoneAssertions interface { - - // Device returns a TwilioDevice for the given number. - // - // It is safe to call multiple times for the same device. - Device(number string) PhoneDevice - - // WaitAndAssert will fail the test if there are any unexpected messages received within the timeout interval. - WaitAndAssert() -} - -// A PhoneDevice immitates a device (i.e. a phone) for testing interactions. -type PhoneDevice interface { - // SendSMS will send a message to GoAlert from the device. - SendSMS(body string) - - // ExpectSMS will match against an SMS that matches ALL provided keywords (case-insensitive). - // Each call to ExpectSMS results in the requirement that an additional SMS is received. - ExpectSMS(keywords ...string) ExpectedSMS - - // RejectSMS will match against an SMS that matches ALL provided keywords (case-insensitive) and tell the server that delivery failed. - RejectSMS(keywords ...string) - - // ExpectVoice will match against a voice call where the spoken text matches ALL provided keywords (case-insensitive). - ExpectVoice(keywords ...string) ExpectedCall - - // RejectVoice will match against a voice call where the spoken text matches ALL provided keywords (case-insensitive) and tell the server that delivery failed. - RejectVoice(keywords ...string) - - // IgnoreUnexpectedSMS will cause any extra SMS messages (after processing ExpectSMS calls) that match - // ALL keywords (case-insensitive) to not fail the test. - IgnoreUnexpectedSMS(keywords ...string) - - // IgnoreUnexpectedVoice will cause any extra voice calls (after processing ExpectVoice) that match - // ALL keywords (case-insensitive) to not fail the test. - IgnoreUnexpectedVoice(keywords ...string) -} - -// ExpectedCall represents a phone call. -type ExpectedCall interface { - // ThenPress imitates a user entering a key on the phone. - ThenPress(digits string) ExpectedCall - - // ThenExpect asserts that the message matches ALL keywords (case-insensitive). - // - // Generally used as ThenPress().ThenExpect() - ThenExpect(keywords ...string) ExpectedCall - - // Body will return the last full spoken message as text. Separate stanzas (e.g. multiple ``) are - // separated by newline. - Body() string - - // Hangup will hangup the active call. - Hangup() -} - -// ExpectedSMS represents an SMS message. -type ExpectedSMS interface { - - // ThenReply will respond with an SMS with the given body. - ThenReply(body string) SMSReply - - // Body is the text of the SMS message. - Body() string - - // From is the source number of the SMS message. - From() string -} - -// SMSReply represents a reply to a received SMS message. -type SMSReply interface { - // ThenExpect will match against an SMS that matches ALL provided keywords (case-insensitive). - // The message must be received AFTER the reply is sent or the assertion will fail. - ThenExpect(keywords ...string) ExpectedSMS -} diff --git a/smoketest/harness/twilioassertionapi.go b/smoketest/harness/twilioassertionapi.go deleted file mode 100644 index 32fe33a10c..0000000000 --- a/smoketest/harness/twilioassertionapi.go +++ /dev/null @@ -1,156 +0,0 @@ -package harness - -import ( - "sync" - "testing" - "time" - - "github.com/target/goalert/devtools/mocktwilio" -) - -type twilioAssertionAPIContext struct { - t *testing.T - *twilioAssertionAPI -} - -func (w *twilioAssertionAPIContext) Device(number string) PhoneDevice { - return w.twilioAssertionAPI.Device(w.t, number) -} -func (w *twilioAssertionAPIContext) WaitAndAssert() { w.twilioAssertionAPI.WaitAndAssert(w.t) } - -type twilioAssertionAPI struct { - *mocktwilio.Server - - triggerFn func() - sendSMSDest string - messages []*mocktwilio.SMS - calls []*mocktwilio.VoiceCall - - activeCalls []*mocktwilio.VoiceCall - - ignoredSMS anyMessage - ignoredVoice anyMessage - - formatNumber func(string) string - - mx sync.Mutex - - abortCh chan struct{} -} - -func newTwilioAssertionAPI(triggerFn func(), formatNumber func(string) string, srv *mocktwilio.Server, sendSMSDest string) *twilioAssertionAPI { - return &twilioAssertionAPI{ - triggerFn: triggerFn, - Server: srv, - sendSMSDest: sendSMSDest, - formatNumber: formatNumber, - abortCh: make(chan struct{}), - } -} -func (tw *twilioAssertionAPI) Close() error { close(tw.abortCh); return tw.Server.Close() } - -func (tw *twilioAssertionAPI) WithT(t *testing.T) PhoneAssertions { - return &twilioAssertionAPIContext{t: t, twilioAssertionAPI: tw} -} - -func (tw *twilioAssertionAPI) Device(t *testing.T, number string) PhoneDevice { - return &twilioAssertionDevice{ - twilioAssertionAPIContext: &twilioAssertionAPIContext{t: t, twilioAssertionAPI: tw}, - - number: number, - } -} - -func (tw *twilioAssertionAPI) triggerTimeout() (<-chan string, func()) { - cancelCh := make(chan struct{}) - - errMsgCh := make(chan string, 1) - t := time.NewTimer(15 * time.Second) - go func() { - defer t.Stop() - minWait := time.NewTimer(3 * time.Second) - defer minWait.Stop() - - // 3 engine cycles, or timeout/cancel (whichever is sooner) - for i := 0; i < 3; i++ { - select { - case <-tw.abortCh: - errMsgCh <- "test exiting" - return - case <-t.C: - errMsgCh <- "15 seconds" - return - default: - tw.triggerFn() - } - } - select { - case <-minWait.C: // wait for the twilio server queue to empty - errMsgCh <- "3 engine cycles" - case <-tw.abortCh: - errMsgCh <- "test exiting" - return - } - }() - - return errMsgCh, func() { close(cancelCh) } -} - -func (tw *twilioAssertionAPI) WaitAndAssert(t *testing.T) { - t.Helper() - if t.Failed() { - // don't wait if test has already failed - return - } - tw.mx.Lock() - defer tw.mx.Unlock() - t.Log("WaitAndAssert: waiting for unexpected messages") - - for _, sms := range tw.messages { - if tw.ignoredSMS.match(sms) { - continue - } - t.Fatalf("found unexpected SMS to %s: %s", tw.formatNumber(sms.To()), sms.Body()) - } - for _, call := range tw.calls { - if tw.ignoredVoice.match(call) { - continue - } - t.Fatalf("found unexpected voice call to %s: %s", tw.formatNumber(call.To()), call.Body()) - } - - timeout, cancel := tw.triggerTimeout() - defer cancel() -waitLoop: - for { - select { - case sms := <-tw.SMS(): - if tw.ignoredSMS.match(sms) { - sms.Accept() - continue - } - t.Fatalf("got unexpected SMS to %s: %s", tw.formatNumber(sms.To()), sms.Body()) - case call := <-tw.VoiceCalls(): - if tw.ignoredVoice.match(call) { - call.Accept() - call.Hangup() - continue - } - t.Fatalf("got unexpected voice call to %s: %s", tw.formatNumber(call.To()), call.Body()) - case <-timeout: - break waitLoop - } - } - - tw.ignoredSMS = nil - tw.ignoredVoice = nil - tw.calls = nil - tw.messages = nil - for _, call := range tw.activeCalls { - call.Hangup() - } - tw.activeCalls = nil -} - -// Twilio will return PhoneAssertions for the given testing context. -func (h *Harness) Twilio(t *testing.T) PhoneAssertions { return h.tw.WithT(t) } diff --git a/smoketest/harness/twilioassertiondevice.go b/smoketest/harness/twilioassertiondevice.go deleted file mode 100644 index f67bfba71f..0000000000 --- a/smoketest/harness/twilioassertiondevice.go +++ /dev/null @@ -1,138 +0,0 @@ -package harness - -import ( - "github.com/target/goalert/devtools/mocktwilio" -) - -type twilioAssertionDevice struct { - *twilioAssertionAPIContext - number string -} - -func (dev *twilioAssertionDevice) newMatcher(keywords []string) messageMatcher { - return messageMatcher{number: dev.number, keywords: keywords} -} - -func (dev *twilioAssertionDevice) _expectVoice(keywords ...string) *twilioAssertionVoiceCall { - dev.t.Helper() - dev.mx.Lock() - defer dev.mx.Unlock() - - m := dev.newMatcher(keywords) - - for i, call := range dev.calls { - if !m.match(call) { - continue - } - - dev.calls = append(dev.calls[:i], dev.calls[i+1:]...) - - return &twilioAssertionVoiceCall{twilioAssertionDevice: dev, VoiceCall: call} - } - - timeout, cancel := dev.triggerTimeout() - defer cancel() - for { - var call *mocktwilio.VoiceCall - select { - case call = <-dev.Server.VoiceCalls(): - case msg := <-timeout: - dev.t.Fatalf("Twilio: timeout after %s waiting for voice call to %s with keywords: %v", msg, dev.formatNumber(dev.number), keywords) - } - dev.t.Logf("received voice call to %s: %s", dev.formatNumber(call.To()), call.Body()) - if !m.match(call) { - dev.calls = append(dev.calls, call) - continue - } - - return &twilioAssertionVoiceCall{twilioAssertionDevice: dev, VoiceCall: call} - } -} - -func (dev *twilioAssertionDevice) _expectSMS(includePrev bool, keywords ...string) *twilioAssertionSMS { - dev.t.Helper() - dev.mx.Lock() - defer dev.mx.Unlock() - - m := dev.newMatcher(keywords) - - if includePrev { - for i, sms := range dev.messages { - if !m.match(sms) { - continue - } - - dev.messages = append(dev.messages[:i], dev.messages[i+1:]...) - return &twilioAssertionSMS{twilioAssertionDevice: dev, SMS: sms} - } - } - - timeout, cancel := dev.triggerTimeout() - defer cancel() - for { - var sms *mocktwilio.SMS - select { - case sms = <-dev.Server.SMS(): - case msg := <-timeout: - dev.t.Fatalf("Twilio: timeout after %s waiting for an SMS to %s with keywords: %v", msg, dev.formatNumber(dev.number), keywords) - } - dev.t.Logf("received SMS to %s: %s", dev.formatNumber(sms.To()), sms.Body()) - if !m.match(sms) { - dev.messages = append(dev.messages, sms) - continue - } - - return &twilioAssertionSMS{twilioAssertionDevice: dev, SMS: sms} - } -} - -func (dev *twilioAssertionDevice) ExpectSMS(keywords ...string) ExpectedSMS { - dev.t.Helper() - sms := dev._expectSMS(true, keywords...) - sms.Accept() - return sms -} -func (dev *twilioAssertionDevice) RejectSMS(keywords ...string) { - dev.t.Helper() - sms := dev._expectSMS(true, keywords...) - sms.Reject() -} -func (sms *twilioAssertionSMS) ThenExpect(keywords ...string) ExpectedSMS { - sms.t.Helper() - sms = sms._expectSMS(false, keywords...) - sms.Accept() - return sms -} - -func (dev *twilioAssertionDevice) ExpectVoice(keywords ...string) ExpectedCall { - dev.t.Helper() - call := dev._expectVoice(keywords...) - dev.mx.Lock() - call.Accept() - dev.activeCalls = append(dev.activeCalls, call.VoiceCall) - dev.mx.Unlock() - return call -} -func (dev *twilioAssertionDevice) RejectVoice(keywords ...string) { - dev.t.Helper() - call := dev._expectVoice(keywords...) - call.Reject() -} -func (dev *twilioAssertionDevice) SendSMS(body string) { - dev.t.Helper() - err := dev.Server.SendSMS(dev.number, dev.sendSMSDest, body) - if err != nil { - dev.t.Fatalf("send SMS: from %s: %v", dev.formatNumber(dev.number), err) - } -} - -func (dev *twilioAssertionDevice) IgnoreUnexpectedSMS(keywords ...string) { - dev.mx.Lock() - defer dev.mx.Unlock() - dev.ignoredSMS = append(dev.ignoredSMS, messageMatcher{number: dev.number, keywords: keywords}) -} -func (dev *twilioAssertionDevice) IgnoreUnexpectedVoice(keywords ...string) { - dev.mx.Lock() - defer dev.mx.Unlock() - dev.ignoredVoice = append(dev.ignoredVoice, messageMatcher{number: dev.number, keywords: keywords}) -} diff --git a/smoketest/harness/twilioassertionsms.go b/smoketest/harness/twilioassertionsms.go deleted file mode 100644 index 9163eb5ddd..0000000000 --- a/smoketest/harness/twilioassertionsms.go +++ /dev/null @@ -1,20 +0,0 @@ -package harness - -import ( - "github.com/target/goalert/devtools/mocktwilio" -) - -type twilioAssertionSMS struct { - *twilioAssertionDevice - *mocktwilio.SMS -} - -var _ ExpectedSMS = &twilioAssertionSMS{} - -func (sms *twilioAssertionSMS) ThenReply(body string) SMSReply { - err := sms.Server.SendSMS(sms.To(), sms.From(), body) - if err != nil { - sms.t.Fatalf("send SMS: from %s: %v", sms.formatNumber(sms.To()), err) - } - return sms -} diff --git a/smoketest/harness/twilioassertionvoicecall.go b/smoketest/harness/twilioassertionvoicecall.go deleted file mode 100644 index 87883eb18b..0000000000 --- a/smoketest/harness/twilioassertionvoicecall.go +++ /dev/null @@ -1,38 +0,0 @@ -package harness - -import ( - "github.com/target/goalert/devtools/mocktwilio" -) - -type twilioAssertionVoiceCall struct { - *twilioAssertionDevice - *mocktwilio.VoiceCall -} - -var _ ExpectedCall = &twilioAssertionVoiceCall{} - -func (call *twilioAssertionVoiceCall) ThenExpect(keywords ...string) ExpectedCall { - call.t.Helper() - msg := call.Body() - if !containsAllIgnoreCase(msg, keywords) { - call.t.Fatalf("voice call message from %s was '%s'; expected keywords: %v", call.From(), msg, keywords) - } - - return call -} -func (call *twilioAssertionVoiceCall) ThenPress(digits string) ExpectedCall { - call.PressDigits(digits) - return call -} -func (call *twilioAssertionVoiceCall) Hangup() { - call.mx.Lock() - defer call.mx.Unlock() - call.VoiceCall.Hangup() - - for i, ac := range call.activeCalls { - if ac == call.VoiceCall { - call.activeCalls = append(call.activeCalls[:i], call.activeCalls[i+1:]...) - break - } - } -} diff --git a/smoketest/twilioonewaysms_test.go b/smoketest/twilioonewaysms_test.go index 3f5c830b91..c8b106075d 100644 --- a/smoketest/twilioonewaysms_test.go +++ b/smoketest/twilioonewaysms_test.go @@ -49,7 +49,7 @@ func TestTwilioOneWaySMS(t *testing.T) { s := d1.ExpectSMS("testing") - assert.NotContains(t, s.Body(), "ack") + assert.NotContains(t, s.Text(), "ack") s.ThenReply("a").ThenExpect("disabled") } diff --git a/smoketest/twiliosmsurl_test.go b/smoketest/twiliosmsurl_test.go index 37cd1972a3..7441585026 100644 --- a/smoketest/twiliosmsurl_test.go +++ b/smoketest/twiliosmsurl_test.go @@ -55,7 +55,6 @@ func TestTwilioURL_SMS(t *testing.T) { h.CreateAlert(h.UUID("sid"), "test") d1.ExpectSMS("test", longURL) - }) t.Run("General.ShortURL in sms body", func(t *testing.T) { @@ -70,7 +69,6 @@ func TestTwilioURL_SMS(t *testing.T) { h.CreateAlert(h.UUID("sid"), "test") d1.ExpectSMS("test", shortURL) - }) t.Run("General.DisableSMSLinks with General.ShortURL set", func(t *testing.T) { @@ -86,7 +84,7 @@ func TestTwilioURL_SMS(t *testing.T) { h.CreateAlert(h.UUID("sid"), "test") smsMsg := d1.ExpectSMS("test") - assert.NotContains(t, smsMsg.Body(), "http") + assert.NotContains(t, smsMsg.Text(), "http") }) t.Run("General.DisableSMSLinks using default URL", func(t *testing.T) { @@ -102,7 +100,7 @@ func TestTwilioURL_SMS(t *testing.T) { h.CreateAlert(h.UUID("sid"), "test") smsMsg := d1.ExpectSMS("test") - assert.NotContains(t, smsMsg.Body(), longURL) - assert.NotContains(t, smsMsg.Body(), "http") + assert.NotContains(t, smsMsg.Text(), longURL) + assert.NotContains(t, smsMsg.Text(), "http") }) } diff --git a/smoketest/twiliosmsverification_test.go b/smoketest/twiliosmsverification_test.go index 76967aee7d..fae5b0d86c 100644 --- a/smoketest/twiliosmsverification_test.go +++ b/smoketest/twiliosmsverification_test.go @@ -58,7 +58,7 @@ func TestTwilioSMSVerification(t *testing.T) { return r } return -1 - }, msg.Body()) + }, msg.Text()) code, _ := strconv.Atoi(codeStr) diff --git a/smoketest/twiliovoiceverification_test.go b/smoketest/twiliovoiceverification_test.go index 4871c0a83b..3a3e74472b 100644 --- a/smoketest/twiliovoiceverification_test.go +++ b/smoketest/twiliovoiceverification_test.go @@ -72,7 +72,7 @@ func TestTwilioVoiceVerification(t *testing.T) { return r } return -1 - }, strings.ReplaceAll(call.Body(), "6-digit", "")) + }, strings.ReplaceAll(call.Text(), "6-digit", "")) // Since verification code is said twice during one Twilio message assert.Len(t, codeStr, 12) From 243efa5cf7680fcc35f052ff933f253de824296f Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 23:35:16 -0500 Subject: [PATCH 04/46] fix server --- devtools/mocktwilio/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index dfe8fc1ca6..1f56af00ac 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -110,6 +110,7 @@ func NewServer(cfg Config) *Server { } srv.msgSvcDB <- make(map[string][]*Number) srv.numbersDB <- make(map[string]*Number) + srv.smsDB <- make(map[string]*sms) srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) From 4c02a8b20e0d2da9075dbfdb88b14cbb781a98f7 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 23:35:25 -0500 Subject: [PATCH 05/46] add simple functionality test --- devtools/mocktwilio/server_test.go | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 devtools/mocktwilio/server_test.go diff --git a/devtools/mocktwilio/server_test.go b/devtools/mocktwilio/server_test.go new file mode 100644 index 0000000000..d77cc41429 --- /dev/null +++ b/devtools/mocktwilio/server_test.go @@ -0,0 +1,80 @@ +package mocktwilio_test + +import ( + "context" + "encoding/json" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/target/goalert/devtools/mocktwilio" +) + +func TestServer(t *testing.T) { + cfg := mocktwilio.Config{ + AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + AuthToken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + OnError: func(ctx context.Context, err error) { + t.Errorf("mocktwilio: error: %v", err) + }, + } + + mux := http.NewServeMux() + mux.HandleFunc("/voice", func(w http.ResponseWriter, req *http.Request) { + t.Error("mocktwilio: unexpected voice request") + w.WriteHeader(204) + }) + mux.HandleFunc("/sms", func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "hello", req.FormValue("Body")) + w.WriteHeader(204) + }) + appHTTP := httptest.NewServer(mux) + defer appHTTP.Close() + + srv := mocktwilio.NewServer(cfg) + twHTTP := httptest.NewServer(srv) + defer twHTTP.Close() + + appPhone := mocktwilio.Number{ + Number: mocktwilio.NewPhoneNumber(), + VoiceWebhookURL: appHTTP.URL + "/voice", + SMSWebhookURL: appHTTP.URL + "/sms", + } + err := srv.AddNumber(appPhone) + require.NoError(t, err) + + // send device to app + devNum := mocktwilio.NewPhoneNumber() + _, err = srv.SendMessage(context.Background(), devNum, appPhone.Number, "hello") + require.NoError(t, err) + + // send app to device + v := make(url.Values) + v.Set("From", appPhone.Number) + v.Set("To", devNum) + v.Set("Body", "world") + resp, err := http.PostForm(twHTTP.URL+"/2010-04-01/Accounts/"+cfg.AccountSID+"/Messages.json", v) + require.NoError(t, err) + + data, err := ioutil.ReadAll(resp.Body) + log.Println(string(data)) + require.Equal(t, 201, resp.StatusCode) + + require.NoError(t, err) + var res struct { + SID string + } + err = json.Unmarshal(data, &res) + require.NoError(t, err) + + msg := <-srv.Messages() + assert.Equal(t, res.SID, msg.ID()) + + err = srv.Close() + require.NoError(t, err) +} From 5317ab6061cb58e64517e2a6b95c864e8bad9c64 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 23:43:05 -0500 Subject: [PATCH 06/46] add post message to db --- devtools/mocktwilio/handlenewmessage.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/devtools/mocktwilio/handlenewmessage.go b/devtools/mocktwilio/handlenewmessage.go index b4634ed80c..87be3cffbe 100644 --- a/devtools/mocktwilio/handlenewmessage.go +++ b/devtools/mocktwilio/handlenewmessage.go @@ -106,6 +106,10 @@ func (srv *Server) HandleNewMessage(w http.ResponseWriter, r *http.Request) { panic(err) } + db := <-srv.smsDB + db[s.ID] = s + srv.smsDB <- db + srv.outboundSMSCh <- s w.Header().Set("Content-Type", "application/json") From 5b35c9cf9191bfba95b8e9bc95b64bb8c88bd17d Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 23:43:24 -0500 Subject: [PATCH 07/46] test additional endpoints --- devtools/mocktwilio/server_test.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/devtools/mocktwilio/server_test.go b/devtools/mocktwilio/server_test.go index d77cc41429..69ac9877f1 100644 --- a/devtools/mocktwilio/server_test.go +++ b/devtools/mocktwilio/server_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "io/ioutil" - "log" "net/http" "net/http/httptest" "net/url" @@ -33,6 +32,10 @@ func TestServer(t *testing.T) { assert.Equal(t, "hello", req.FormValue("Body")) w.WriteHeader(204) }) + mux.HandleFunc("/status", func(w http.ResponseWriter, req *http.Request) { + t.Log(req.URL.Path) + w.WriteHeader(204) + }) appHTTP := httptest.NewServer(mux) defer appHTTP.Close() @@ -58,11 +61,13 @@ func TestServer(t *testing.T) { v.Set("From", appPhone.Number) v.Set("To", devNum) v.Set("Body", "world") + v.Set("StatusCallback", appHTTP.URL+"/status") resp, err := http.PostForm(twHTTP.URL+"/2010-04-01/Accounts/"+cfg.AccountSID+"/Messages.json", v) require.NoError(t, err) data, err := ioutil.ReadAll(resp.Body) - log.Println(string(data)) + require.NoError(t, err) + t.Log("Response:", string(data)) require.Equal(t, 201, resp.StatusCode) require.NoError(t, err) @@ -75,6 +80,14 @@ func TestServer(t *testing.T) { msg := <-srv.Messages() assert.Equal(t, res.SID, msg.ID()) + resp, err = http.Get(twHTTP.URL + "/2010-04-01/Accounts/" + cfg.AccountSID + "/Messages/" + res.SID + ".json") + require.NoError(t, err) + + data, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + t.Log("Response:", string(data)) + require.Equal(t, 200, resp.StatusCode) + err = srv.Close() require.NoError(t, err) } From ca4236453e32aa81474d7ac6df81f41893189890 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 23:43:33 -0500 Subject: [PATCH 08/46] fix startup race --- smoketest/harness/harness.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/smoketest/harness/harness.go b/smoketest/harness/harness.go index 555baaff7b..5090f69670 100644 --- a/smoketest/harness/harness.go +++ b/smoketest/harness/harness.go @@ -199,16 +199,6 @@ func NewStoppedHarness(t *testing.T, initSQL string, sqlData interface{}, migrat t.Error(err) }, }) - h.tw = mocktwilio.NewAssertions(t, mocktwilio.AssertConfig{ - ServerAPI: h.mockTw, - Timeout: 15 * time.Second, - AppPhoneNumber: h.TwilioNumber(""), - RefreshFunc: func() { - t.Helper() - h.FastForward(time.Second) - h.Trigger() - }, - }) h.twS = httptest.NewServer(h.mockTw) @@ -296,7 +286,16 @@ func (h *Harness) Start() { if err != nil { h.t.Fatalf("failed to start backend: %v", err) } - h.TwilioNumber("") // register default number + h.tw = mocktwilio.NewAssertions(h.t, mocktwilio.AssertConfig{ + ServerAPI: h.mockTw, + Timeout: 15 * time.Second, + AppPhoneNumber: h.TwilioNumber(""), + RefreshFunc: func() { + h.t.Helper() + h.FastForward(time.Second) + h.Trigger() + }, + }) h.slack.SetActionURL(h.slackApp.ClientID, h.backend.URL()+"/api/v2/slack/message-action") go h.backend.Run(context.Background()) From 4c1b658947961b1ed5f415e8a777ea1f7cea5353 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 16 Aug 2022 23:57:09 -0500 Subject: [PATCH 09/46] put date in url --- notification/twilio/client.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/notification/twilio/client.go b/notification/twilio/client.go index f7e7d71449..567ca578c6 100644 --- a/notification/twilio/client.go +++ b/notification/twilio/client.go @@ -17,7 +17,7 @@ import ( ) // DefaultTwilioAPIURL is the value that will be used for API calls if Config.BaseURL is empty. -const DefaultTwilioAPIURL = "https://api.twilio.com/2010-04-01" +const DefaultTwilioAPIURL = "https://api.twilio.com" // SMSOptions allows configuring outgoing SMS messages. type SMSOptions struct { @@ -71,6 +71,7 @@ func urlJoin(base string, parts ...string) string { } return base + "/" + strings.Join(parts, "/") } + func (c *Config) url(parts ...string) string { base := c.BaseURL if base == "" { @@ -78,6 +79,7 @@ func (c *Config) url(parts ...string) string { } return urlJoin(base, parts...) } + func (c *Config) httpClient() *http.Client { if c.Client != nil { return c.Client @@ -85,6 +87,7 @@ func (c *Config) httpClient() *http.Client { return http.DefaultClient } + func (c *Config) get(ctx context.Context, urlStr string) (*http.Response, error) { req, err := http.NewRequest("GET", urlStr, nil) if err != nil { @@ -98,6 +101,7 @@ func (c *Config) get(ctx context.Context, urlStr string) (*http.Response, error) return c.httpClient().Do(req) } + func (c *Config) post(ctx context.Context, urlStr string, v url.Values) (*http.Response, error) { req, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(v.Encode())) if err != nil { @@ -114,7 +118,7 @@ func (c *Config) post(ctx context.Context, urlStr string, v url.Values) (*http.R // GetSMS will return the current state of a Message from Twilio. func (c *Config) GetSMS(ctx context.Context, sid string) (*Message, error) { cfg := config.FromContext(ctx) - urlStr := c.url("Accounts", cfg.Twilio.AccountSID, "Messages", sid+".json") + urlStr := c.url("2010-04-01", "Accounts", cfg.Twilio.AccountSID, "Messages", sid+".json") resp, err := c.get(ctx, urlStr) if err != nil { return nil, err @@ -130,7 +134,7 @@ func (c *Config) GetSMS(ctx context.Context, sid string) (*Message, error) { var e Exception err = json.Unmarshal(data, &e) if err != nil { - return nil, errors.Wrap(err, "parse error response") + return nil, errors.Wrapf(err, "parse error response %s", string(data)) } return nil, &e } @@ -147,7 +151,7 @@ func (c *Config) GetSMS(ctx context.Context, sid string) (*Message, error) { // GetVoice will return the current state of a voice call from Twilio. func (c *Config) GetVoice(ctx context.Context, sid string) (*Call, error) { cfg := config.FromContext(ctx) - urlStr := c.url("Accounts", cfg.Twilio.AccountSID, "Calls", sid+".json") + urlStr := c.url("2010-04-01", "Accounts", cfg.Twilio.AccountSID, "Calls", sid+".json") resp, err := c.post(ctx, urlStr, nil) if err != nil { return nil, err @@ -226,7 +230,7 @@ func (c *Config) StartVoice(ctx context.Context, to string, o *VoiceOptions) (*C v.Add("StatusCallbackEvent", "answered") v.Add("StatusCallbackEvent", "completed") o.apply(v) - urlStr := c.url("Accounts", cfg.Twilio.AccountSID, "Calls.json") + urlStr := c.url("2010-04-01", "Accounts", cfg.Twilio.AccountSID, "Calls.json") resp, err := c.post(ctx, urlStr, v) if err != nil { @@ -291,7 +295,7 @@ func (c *Config) SendSMS(ctx context.Context, to, body string, o *SMSOptions) (* } v.Set("StatusCallback", stat) o.apply(v) - urlStr := c.url("Accounts", cfg.Twilio.AccountSID, "Messages.json") + urlStr := c.url("2010-04-01", "Accounts", cfg.Twilio.AccountSID, "Messages.json") resp, err := c.post(ctx, urlStr, v) if err != nil { @@ -307,7 +311,7 @@ func (c *Config) SendSMS(ctx context.Context, to, body string, o *SMSOptions) (* var e Exception err = json.Unmarshal(data, &e) if err != nil { - return nil, errors.Wrap(err, "parse error response") + return nil, errors.Wrapf(err, "parse error response %s", string(data)) } return nil, &e } From 251f46a31f455700c25306dda0943b496524da13 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 17 Aug 2022 00:07:12 -0500 Subject: [PATCH 10/46] add better logs --- devtools/mocktwilio/assertsms.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go index 410f254646..7d920af981 100644 --- a/devtools/mocktwilio/assertsms.go +++ b/devtools/mocktwilio/assertsms.go @@ -71,6 +71,7 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords dev.t.Fatalf("mocktwilio: error setting SMS status %s to %s: %v", status, msg.To(), err) } + dev.t.Log("mocktwilio: received expected SMS from", msg.From(), "to", msg.To(), "with text", msg.Text()) return &assertSMS{assertDev: dev, Message: msg} } } @@ -83,6 +84,7 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords for { select { case <-t.C: + dev.t.Log("mocktwilio: messages:", dev.messages) dev.t.Fatalf("mocktwilio: timeout after %s waiting for an SMS to %s with keywords: %v", dev.Timeout, dev.number, keywords) case msg := <-dev.Messages(): if !dev.matchMessage(dev.number, keywords, msg) { @@ -98,6 +100,7 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords dev.t.Fatalf("mocktwilio: error setting SMS status %s to %s: %v", status, msg.To(), err) } + dev.t.Log("mocktwilio: received expected SMS from", msg.From(), "to", msg.To(), "with text", msg.Text()) return &assertSMS{assertDev: dev, Message: msg} } } From 88e6e20665779fd3d462ccd8e2f70b7eb1e41e30 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 17 Aug 2022 00:07:22 -0500 Subject: [PATCH 11/46] fix fast-forward time --- smoketest/harness/harness.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smoketest/harness/harness.go b/smoketest/harness/harness.go index 5090f69670..78fac96e15 100644 --- a/smoketest/harness/harness.go +++ b/smoketest/harness/harness.go @@ -292,7 +292,7 @@ func (h *Harness) Start() { AppPhoneNumber: h.TwilioNumber(""), RefreshFunc: func() { h.t.Helper() - h.FastForward(time.Second) + h.FastForward(time.Minute) h.Trigger() }, }) From 53c4a88207d15e8f9a8656202e5f937fdea5e0ea Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 17 Aug 2022 00:10:39 -0500 Subject: [PATCH 12/46] clairify message output --- devtools/mocktwilio/assertsms.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go index 7d920af981..7974d8d780 100644 --- a/devtools/mocktwilio/assertsms.go +++ b/devtools/mocktwilio/assertsms.go @@ -84,8 +84,13 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords for { select { case <-t.C: - dev.t.Log("mocktwilio: messages:", dev.messages) - dev.t.Fatalf("mocktwilio: timeout after %s waiting for an SMS to %s with keywords: %v", dev.Timeout, dev.number, keywords) + + dev.t.Errorf("mocktwilio: timeout after %s waiting for an SMS to %s with keywords: %v", dev.Timeout, dev.number, keywords) + for i, msg := range dev.messages { + dev.t.Errorf("mocktwilio: message %d: from=%s; to=%s; text=%s", i, msg.From(), msg.To(), msg.Text()) + } + + dev.t.FailNow() case msg := <-dev.Messages(): if !dev.matchMessage(dev.number, keywords, msg) { dev.messages = append(dev.messages, msg) From 6b9059c915d3079945035333f42559f8c5230350 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 17 Aug 2022 10:24:12 -0500 Subject: [PATCH 13/46] fix waitandassert --- devtools/mocktwilio/assert.go | 14 ++++++++------ devtools/mocktwilio/assertcall.go | 5 ++++- devtools/mocktwilio/assertsms.go | 6 +++++- devtools/mocktwilio/server.go | 29 ++++++++++++++++++++++++++++- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/devtools/mocktwilio/assert.go b/devtools/mocktwilio/assert.go index 4f05b37186..f1da1ec3b0 100644 --- a/devtools/mocktwilio/assert.go +++ b/devtools/mocktwilio/assert.go @@ -21,6 +21,7 @@ type AssertConfig struct { type ServerAPI interface { SendMessage(ctx context.Context, from, to, body string) (Message, error) + WaitInFlight(context.Context) error Messages() <-chan Message Calls() <-chan Call } @@ -103,6 +104,7 @@ func (a *assert) WaitAndAssert() { // flush any remaining application messages a.refresh() + a.ServerAPI.WaitInFlight(context.Background()) drainMessages: for { @@ -132,10 +134,10 @@ checkMessages: if a.matchMessage(ignore.number, ignore.keywords, msg) { continue checkMessages } - - hasFailure = true - a.t.Errorf("mocktwilio: unexpected SMS to %s: %s", msg.To(), msg.Text()) } + + hasFailure = true + a.t.Errorf("mocktwilio: unexpected SMS to %s: %s", msg.To(), msg.Text()) } checkCalls: @@ -144,10 +146,10 @@ checkCalls: if a.matchMessage(ignore.number, ignore.keywords, call) { continue checkCalls } - - hasFailure = true - a.t.Errorf("mocktwilio: unexpected call to %s: %s", call.To(), call.Text()) } + + hasFailure = true + a.t.Errorf("mocktwilio: unexpected call to %s: %s", call.To(), call.Text()) } if hasFailure { diff --git a/devtools/mocktwilio/assertcall.go b/devtools/mocktwilio/assertcall.go index 2662b39b47..c994d12839 100644 --- a/devtools/mocktwilio/assertcall.go +++ b/devtools/mocktwilio/assertcall.go @@ -75,11 +75,14 @@ func (dev *assertDev) getVoice(prev bool, keywords []string) Call { dev.t.Helper() if prev { - for _, call := range dev.calls { + for idx, call := range dev.calls { if !dev.matchMessage(dev.number, keywords, call) { continue } + // Remove the call from the list of calls. + dev.calls = append(dev.calls[:idx], dev.calls[idx+1:]...) + return call } } diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go index 7974d8d780..a03e2079a1 100644 --- a/devtools/mocktwilio/assertsms.go +++ b/devtools/mocktwilio/assertsms.go @@ -58,7 +58,7 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords keywords = toLowerSlice(keywords) if prev { - for _, msg := range dev.messages { + for idx, msg := range dev.messages { if !dev.matchMessage(dev.number, keywords, msg) { continue } @@ -72,6 +72,10 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords } dev.t.Log("mocktwilio: received expected SMS from", msg.From(), "to", msg.To(), "with text", msg.Text()) + + // remove the message from the list of messages + dev.messages = append(dev.messages[:idx], dev.messages[idx+1:]...) + return &assertSMS{assertDev: dev, Message: msg} } } diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index 1f56af00ac..f86db84aef 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -63,6 +63,8 @@ type Server struct { numbersDB chan map[string]*Number msgSvcDB chan map[string][]*Number + waitInFlight chan chan struct{} + mux *http.ServeMux once sync.Once @@ -102,11 +104,13 @@ func NewServer(cfg Config) *Server { mux: http.NewServeMux(), smsDB: make(chan map[string]*sms, 1), - messagesCh: make(chan Message), + messagesCh: make(chan Message, 10000), outboundSMSCh: make(chan *sms), shutdown: make(chan struct{}), shutdownDone: make(chan struct{}), + + waitInFlight: make(chan chan struct{}), } srv.msgSvcDB <- make(map[string][]*Number) srv.numbersDB <- make(map[string]*Number) @@ -260,10 +264,33 @@ func (srv *Server) loop() { sms.lifecycle(ctx) wg.Done() }() + case ch := <-srv.waitInFlight: + go func() { + wg.Wait() + close(ch) + }() } } } +// WaitInFlight waits for all in-flight requests/messages/calls to complete. +func (srv *Server) WaitInFlight(ctx context.Context) error { + ch := make(chan struct{}) + select { + case srv.waitInFlight <- ch: + case <-ctx.Done(): + return ctx.Err() + } + + select { + case <-ch: + case <-ctx.Done(): + return ctx.Err() + } + + return nil +} + func (s *Server) post(ctx context.Context, url string, v url.Values) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(v.Encode())) if err != nil { From cae20fc54596a868ec10a5e4399933f0af6bbfc3 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 17 Aug 2022 11:36:57 -0500 Subject: [PATCH 14/46] code organization --- devtools/mocktwilio/handlemessagestatus.go | 4 +- devtools/mocktwilio/handlenewmessage.go | 8 +- devtools/mocktwilio/http.go | 69 +++++++++++++++++ devtools/mocktwilio/message.go | 12 +-- devtools/mocktwilio/{sms.go => msgstate.go} | 18 ++--- devtools/mocktwilio/sendmessage.go | 6 +- devtools/mocktwilio/server.go | 83 +++++---------------- 7 files changed, 110 insertions(+), 90 deletions(-) create mode 100644 devtools/mocktwilio/http.go rename devtools/mocktwilio/{sms.go => msgstate.go} (84%) diff --git a/devtools/mocktwilio/handlemessagestatus.go b/devtools/mocktwilio/handlemessagestatus.go index 40280c6cd0..95582aadf2 100644 --- a/devtools/mocktwilio/handlemessagestatus.go +++ b/devtools/mocktwilio/handlemessagestatus.go @@ -21,9 +21,9 @@ func (srv *Server) HandleMessageStatus(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, srv.basePath()+"/Messages/") id = strings.TrimSuffix(id, ".json") - db := <-srv.smsDB + db := <-srv.msgStateDB s := db[id] - srv.smsDB <- db + srv.msgStateDB <- db if s == nil { respondErr(w, twError{ diff --git a/devtools/mocktwilio/handlenewmessage.go b/devtools/mocktwilio/handlenewmessage.go index 87be3cffbe..558e9e3d1d 100644 --- a/devtools/mocktwilio/handlenewmessage.go +++ b/devtools/mocktwilio/handlenewmessage.go @@ -19,7 +19,7 @@ func (srv *Server) HandleNewMessage(w http.ResponseWriter, r *http.Request) { return } - s := srv.newSMS() + s := srv.newMsgState() s.Direction = "outbound-api" s.To = r.FormValue("To") s.From = r.FormValue("From") @@ -106,11 +106,11 @@ func (srv *Server) HandleNewMessage(w http.ResponseWriter, r *http.Request) { panic(err) } - db := <-srv.smsDB + db := <-srv.msgStateDB db[s.ID] = s - srv.smsDB <- db + srv.msgStateDB <- db - srv.outboundSMSCh <- s + srv.outboundMsgCh <- s w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) diff --git a/devtools/mocktwilio/http.go b/devtools/mocktwilio/http.go new file mode 100644 index 0000000000..e5d7f9578e --- /dev/null +++ b/devtools/mocktwilio/http.go @@ -0,0 +1,69 @@ +package mocktwilio + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + + "github.com/pkg/errors" +) + +func (srv *Server) initHTTP() { + srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) + srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) + // s.mux.HandleFunc(base+"/Calls.json", s.serveNewCall) + // s.mux.HandleFunc(base+"/Calls/", s.serveCallStatus) + // s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) +} + +func (srv *Server) basePath() string { + return "/2010-04-01/Accounts/" + srv.cfg.AccountSID +} + +func (s *Server) post(ctx context.Context, url string, v url.Values) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(v.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Twilio-Signature", Signature(s.cfg.AuthToken, url, v)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode/100 != 2 { + return nil, errors.Errorf("non-2xx response: %s", resp.Status) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if len(data) == 0 && resp.StatusCode != 204 { + return nil, errors.Errorf("non-204 response on empty body: %s", resp.Status) + } + + return data, nil +} + +// ServeHTTP implements the http.Handler interface for serving [mock] API requests. +func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if s.cfg.EnableAuth { + user, pass, ok := req.BasicAuth() + if !ok || user != s.cfg.AccountSID || pass != s.cfg.AuthToken { + respondErr(w, twError{ + Status: 401, + Code: 20003, + Message: "Authenticate", + }) + return + } + } + + s.mux.ServeHTTP(w, req) +} diff --git a/devtools/mocktwilio/message.go b/devtools/mocktwilio/message.go index 3d5524587b..0f1ce37749 100644 --- a/devtools/mocktwilio/message.go +++ b/devtools/mocktwilio/message.go @@ -26,16 +26,16 @@ const ( ) // Messages returns a channel of outbound messages. -func (srv *Server) Messages() <-chan Message { return srv.messagesCh } +func (srv *Server) Messages() <-chan Message { return srv.msgCh } type message struct { - *sms + *msgState } -func (msg *message) ID() string { return msg.sms.ID } -func (msg *message) To() string { return msg.sms.To } -func (msg *message) From() string { return msg.sms.From } -func (msg *message) Text() string { return msg.sms.Body } +func (msg *message) ID() string { return msg.msgState.ID } +func (msg *message) To() string { return msg.msgState.To } +func (msg *message) From() string { return msg.msgState.From } +func (msg *message) Text() string { return msg.msgState.Body } func (msg *message) SetStatus(ctx context.Context, status FinalMessageStatus) error { return msg.setFinalStatus(ctx, status, 0) diff --git a/devtools/mocktwilio/sms.go b/devtools/mocktwilio/msgstate.go similarity index 84% rename from devtools/mocktwilio/sms.go rename to devtools/mocktwilio/msgstate.go index 44e1139a56..1c6a8c0149 100644 --- a/devtools/mocktwilio/sms.go +++ b/devtools/mocktwilio/msgstate.go @@ -10,7 +10,7 @@ import ( "time" ) -type sms struct { +type msgState struct { Body string `json:"body"` CreatedAt time.Time `json:"date_created"` SentAt *time.Time `json:"date_sent,omitempty"` @@ -30,9 +30,9 @@ type sms struct { final sync.Once } -func (srv *Server) newSMS() *sms { +func (srv *Server) newMsgState() *msgState { n := time.Now() - return &sms{ + return &msgState{ setFinal: make(chan FinalMessageStatus, 1), CreatedAt: n, UpdatedAt: n, @@ -41,7 +41,7 @@ func (srv *Server) newSMS() *sms { } } -func (s *sms) lifecycle(ctx context.Context) { +func (s *msgState) lifecycle(ctx context.Context) { if s.MsgSID != "" { nums := s.srv.numberSvc(s.MsgSID) idx := rand.Intn(len(nums)) @@ -61,7 +61,7 @@ func (s *sms) lifecycle(ctx context.Context) { if n == nil { select { case <-ctx.Done(): - case s.srv.messagesCh <- &message{s}: + case s.srv.msgCh <- &message{s}: } return } @@ -79,7 +79,7 @@ func (s *sms) lifecycle(ctx context.Context) { } } -func (s *sms) setSendStatus(ctx context.Context, status, updateFrom string) error { +func (s *msgState) setSendStatus(ctx context.Context, status, updateFrom string) error { s.mx.Lock() if updateFrom != "" { s.From = updateFrom @@ -115,7 +115,7 @@ func (s *sms) setSendStatus(ctx context.Context, status, updateFrom string) erro return nil } -func (s *sms) setFinalStatus(ctx context.Context, status FinalMessageStatus, code int) error { +func (s *msgState) setFinalStatus(ctx context.Context, status FinalMessageStatus, code int) error { var err error s.final.Do(func() { err = s.setSendStatus(ctx, string(status), "") @@ -124,12 +124,12 @@ func (s *sms) setFinalStatus(ctx context.Context, status FinalMessageStatus, cod return err } -func (s *sms) MarshalJSON() ([]byte, error) { +func (s *msgState) MarshalJSON() ([]byte, error) { if s == nil { return []byte("null"), nil } - type data sms + type data msgState s.mx.Lock() defer s.mx.Unlock() return json.Marshal((*data)(s)) diff --git a/devtools/mocktwilio/sendmessage.go b/devtools/mocktwilio/sendmessage.go index 7b86ec7410..a6bc647f83 100644 --- a/devtools/mocktwilio/sendmessage.go +++ b/devtools/mocktwilio/sendmessage.go @@ -31,7 +31,7 @@ func (srv *Server) SendMessage(ctx context.Context, from, to, body string) (Mess return nil, fmt.Errorf("no SMS webhook URL registered for number: %s", to) } - s := srv.newSMS() + s := srv.newMsgState() s.Direction = "inbound" s.To = to s.From = from @@ -56,9 +56,9 @@ func (srv *Server) SendMessage(ctx context.Context, from, to, body string) (Mess v.Set("To", to) // to city/country/state/zip omitted - db := <-srv.smsDB + db := <-srv.msgStateDB db[s.ID] = s - srv.smsDB <- db + srv.msgStateDB <- db _, err = srv.post(ctx, n.SMSWebhookURL, v) if err != nil { diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index f86db84aef..76d00cf2c5 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -3,7 +3,6 @@ package mocktwilio import ( "context" "fmt" - "io" "net/http" "net/url" "strings" @@ -56,9 +55,13 @@ type MsgService struct { type Server struct { cfg Config - smsDB chan map[string]*sms - messagesCh chan Message - outboundSMSCh chan *sms + msgCh chan Message + msgStateDB chan map[string]*msgState + outboundMsgCh chan *msgState + + callsCh chan Call + callStateDB chan map[string]*callState + outboundCallCh chan *callState numbersDB chan map[string]*Number msgSvcDB chan map[string][]*Number @@ -103,9 +106,13 @@ func NewServer(cfg Config) *Server { numbersDB: make(chan map[string]*Number, 1), mux: http.NewServeMux(), - smsDB: make(chan map[string]*sms, 1), - messagesCh: make(chan Message, 10000), - outboundSMSCh: make(chan *sms), + msgCh: make(chan Message, 10000), + msgStateDB: make(chan map[string]*msgState, 1), + outboundMsgCh: make(chan *msgState), + + callsCh: make(chan Call, 10000), + callStateDB: make(chan map[string]*callState, 1), + outboundCallCh: make(chan *callState), shutdown: make(chan struct{}), shutdownDone: make(chan struct{}), @@ -114,13 +121,7 @@ func NewServer(cfg Config) *Server { } srv.msgSvcDB <- make(map[string][]*Number) srv.numbersDB <- make(map[string]*Number) - srv.smsDB <- make(map[string]*sms) - - srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) - srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) - // s.mux.HandleFunc(base+"/Calls.json", s.serveNewCall) - // s.mux.HandleFunc(base+"/Calls/", s.serveCallStatus) - // s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) + srv.msgStateDB <- make(map[string]*msgState) go srv.loop() @@ -217,10 +218,6 @@ func (srv *Server) AddMsgService(ms MsgService) error { return nil } -func (srv *Server) basePath() string { - return "/2010-04-01/Accounts/" + srv.cfg.AccountSID -} - func (srv *Server) nextID(prefix string) string { return fmt.Sprintf("%s%032d", prefix, atomic.AddUint64(&srv.id, 1)) } @@ -247,7 +244,7 @@ func (srv *Server) loop() { var wg sync.WaitGroup defer close(srv.shutdownDone) - defer close(srv.messagesCh) + defer close(srv.msgCh) defer wg.Wait() ctx, cancel := context.WithCancel(context.Background()) @@ -258,7 +255,7 @@ func (srv *Server) loop() { case <-srv.shutdown: return - case sms := <-srv.outboundSMSCh: + case sms := <-srv.outboundMsgCh: wg.Add(1) go func() { sms.lifecycle(ctx) @@ -290,49 +287,3 @@ func (srv *Server) WaitInFlight(ctx context.Context) error { return nil } - -func (s *Server) post(ctx context.Context, url string, v url.Values) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(v.Encode())) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("X-Twilio-Signature", Signature(s.cfg.AuthToken, url, v)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - if resp.StatusCode/100 != 2 { - return nil, errors.Errorf("non-2xx response: %s", resp.Status) - } - defer resp.Body.Close() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if len(data) == 0 && resp.StatusCode != 204 { - return nil, errors.Errorf("non-204 response on empty body: %s", resp.Status) - } - - return data, nil -} - -// ServeHTTP implements the http.Handler interface for serving [mock] API requests. -func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { - if s.cfg.EnableAuth { - user, pass, ok := req.BasicAuth() - if !ok || user != s.cfg.AccountSID || pass != s.cfg.AuthToken { - respondErr(w, twError{ - Status: 401, - Code: 20003, - Message: "Authenticate", - }) - return - } - } - - s.mux.ServeHTTP(w, req) -} From 4aca25b9c0020d52638c3fb165de6529d84ff7e7 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 19 Aug 2022 12:31:20 -0500 Subject: [PATCH 15/46] init routes --- devtools/mocktwilio/http.go | 8 ++++---- devtools/mocktwilio/server.go | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/devtools/mocktwilio/http.go b/devtools/mocktwilio/http.go index e5d7f9578e..2dfa4b05cd 100644 --- a/devtools/mocktwilio/http.go +++ b/devtools/mocktwilio/http.go @@ -10,6 +10,10 @@ import ( "github.com/pkg/errors" ) +func (srv *Server) basePath() string { + return "/2010-04-01/Accounts/" + srv.cfg.AccountSID +} + func (srv *Server) initHTTP() { srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) @@ -18,10 +22,6 @@ func (srv *Server) initHTTP() { // s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) } -func (srv *Server) basePath() string { - return "/2010-04-01/Accounts/" + srv.cfg.AccountSID -} - func (s *Server) post(ctx context.Context, url string, v url.Values) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(v.Encode())) if err != nil { diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index 76d00cf2c5..bd572ec049 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -123,6 +123,8 @@ func NewServer(cfg Config) *Server { srv.numbersDB <- make(map[string]*Number) srv.msgStateDB <- make(map[string]*msgState) + srv.initHTTP() + go srv.loop() return srv @@ -223,6 +225,9 @@ func (srv *Server) nextID(prefix string) string { } func (srv *Server) logErr(ctx context.Context, err error) { + if err == nil { + return + } if srv.cfg.OnError == nil { return } From cd942385bd4431edce4065fd60bbb4e3e256be31 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 23 Aug 2022 14:52:33 -0500 Subject: [PATCH 16/46] handle call routing --- devtools/mocktwilio/call.go | 12 +- devtools/mocktwilio/callstate.go | 167 ++++++++++++++++++++++++ devtools/mocktwilio/errors.go | 26 ++++ devtools/mocktwilio/handlenewcall.go | 117 +++++++++++++++++ devtools/mocktwilio/handlenewmessage.go | 2 +- devtools/mocktwilio/server.go | 12 +- devtools/mocktwilio/server_test.go | 166 ++++++++++++++--------- devtools/mocktwilio/strings.go | 14 +- 8 files changed, 444 insertions(+), 72 deletions(-) create mode 100644 devtools/mocktwilio/callstate.go create mode 100644 devtools/mocktwilio/handlenewcall.go diff --git a/devtools/mocktwilio/call.go b/devtools/mocktwilio/call.go index c4a7dfea89..cd81e96f4b 100644 --- a/devtools/mocktwilio/call.go +++ b/devtools/mocktwilio/call.go @@ -13,6 +13,8 @@ type Call interface { Answer(context.Context) error + IsActive() bool + // Press will simulate a press of the specified key. // // It does nothing if Answer has not been called or @@ -32,10 +34,16 @@ const ( CallCanceled FinalCallStatus = "canceled" ) -func (srv *Server) Calls() <-chan Call { - return nil +type call struct { + *callState } +func (c *call) ID() string { return c.callState.ID } +func (c *call) From() string { return c.callState.From } +func (c *call) To() string { return c.callState.To } + +func (srv *Server) Calls() <-chan Call { return srv.callCh } + // StartCall will start a new voice call. func (srv *Server) StartCall(ctx context.Context, from, to string) error { return nil diff --git a/devtools/mocktwilio/callstate.go b/devtools/mocktwilio/callstate.go new file mode 100644 index 0000000000..c9a7ab84d1 --- /dev/null +++ b/devtools/mocktwilio/callstate.go @@ -0,0 +1,167 @@ +package mocktwilio + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + "sync" + "time" +) + +type callState struct { + CreatedAt time.Time `json:"date_created"` + UpdatedAt time.Time `json:"date_updated"` + StartedAt *time.Time `json:"start_time,omitempty"` + EndedAt *time.Time `json:"end_time,omitempty"` + Duration string `json:"duration,omitempty"` + Direction string `json:"direction"` + MsgSID string `json:"messaging_service_sid,omitempty"` + ID string `json:"sid"` + Status string `json:"status"` + To string `json:"to"` + From string `json:"from"` + + StatusURL string `json:"-"` + CallURL string `json:"-"` + + srv *Server + mx sync.Mutex + + action chan struct{} + lastResp string +} + +func (srv *Server) newCallState() *callState { + n := time.Now() + return &callState{ + srv: srv, + ID: srv.nextID("CA"), + CreatedAt: n, + UpdatedAt: n, + action: make(chan struct{}, 1), + } +} + +func (s *callState) Text() string { + s.mx.Lock() + defer s.mx.Unlock() + return s.lastResp +} + +func (s *callState) IsActive() bool { + switch s.status() { + case "completed", "failed", "busy", "no-answer", "canceled": + return false + } + + return true +} + +func (s *callState) Answer(ctx context.Context) error { + s.action <- struct{}{} + if s.status() != "ringing" { + <-s.action + return nil + } + + s.setStatus(ctx, "in-progress") + + err := s.update(ctx, "") + if err != nil { + s.setStatus(ctx, "failed") + <-s.action + return err + } + + <-s.action + return nil +} + +func (s *callState) update(ctx context.Context, digits string) error { + v := make(url.Values) + v.Set("AccountSid", s.srv.cfg.AccountSID) + v.Set("ApiVersion", "2010-04-01") + v.Set("CallSid", s.ID) + v.Set("CallStatus", "in-progress") + v.Set("Direction", s.Direction) + v.Set("From", s.From) + v.Set("To", s.To) + + return nil +} + +func (s *callState) status() string { + s.mx.Lock() + defer s.mx.Unlock() + return s.Status +} + +func (s *callState) lifecycle(ctx context.Context) { +} + +func (s *callState) setStatus(ctx context.Context, status string) { + s.mx.Lock() + defer s.mx.Unlock() + s.Status = status + s.UpdatedAt = time.Now() + switch status { + case "in-progress": + s.StartedAt = &s.UpdatedAt + case "completed", "failed", "busy", "no-answer", "canceled": + s.EndedAt = &s.UpdatedAt + if s.StartedAt == nil { + s.StartedAt = &s.UpdatedAt + } + } + + // TODO: post status, pass to logErr +} + +func (s *callState) Press(ctx context.Context, key string) error { + s.action <- struct{}{} + if s.status() != "in-progress" { + <-s.action + return fmt.Errorf("call not in progress") + } + + err := s.update(ctx, "") + if err != nil { + s.setStatus(ctx, "failed") + <-s.action + return err + } + + <-s.action + return nil +} + +func (s *callState) End(ctx context.Context, status FinalCallStatus) error { + s.action <- struct{}{} + if !s.IsActive() { + <-s.action + return nil + } + + s.setStatus(ctx, string(status)) + return nil +} + +func (v *callState) MarshalJSON() ([]byte, error) { + if v == nil { + return []byte("null"), nil + } + + v.mx.Lock() + defer v.mx.Unlock() + + if v.StartedAt != nil && v.EndedAt != nil { + v.Duration = strconv.Itoa(int(v.EndedAt.Sub(*v.StartedAt).Seconds())) + } else { + v.Duration = "" + } + + type data callState + return json.Marshal((*data)(v)) +} diff --git a/devtools/mocktwilio/errors.go b/devtools/mocktwilio/errors.go index 9df2e95211..a8d261ee20 100644 --- a/devtools/mocktwilio/errors.go +++ b/devtools/mocktwilio/errors.go @@ -20,3 +20,29 @@ func respondErr(w http.ResponseWriter, err twError) { err.Info = "https://www.twilio.com/docs/errors/" + strconv.Itoa(err.Code) json.NewEncoder(w).Encode(err) } + +// IsStatusUpdateErr returns true if the error is from a status update. +func IsStatusUpdateErr(err error) bool { + type statErr interface { + IsStatusUpdate() bool + } + + if err == nil { + return false + } + + e, ok := err.(statErr) + return ok && e.IsStatusUpdate() +} + +type statusErr struct { + err error +} + +func (s statusErr) IsStatusUpdate() bool { return true } + +func (s statusErr) Error() string { + return s.Error() +} + +func (s statusErr) Unwrap() error { return s.err } diff --git a/devtools/mocktwilio/handlenewcall.go b/devtools/mocktwilio/handlenewcall.go new file mode 100644 index 0000000000..bfc052595e --- /dev/null +++ b/devtools/mocktwilio/handlenewcall.go @@ -0,0 +1,117 @@ +package mocktwilio + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/ttacon/libphonenumber" +) + +// HandleNewMessage handles POST requests to /2010-04-01/Accounts//Calls.json +func (srv *Server) HandleNewCall(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + respondErr(w, twError{ + Status: 405, + Code: 20004, + Message: "Method not allowed", + }) + return + } + + s := srv.newCallState() + s.Direction = "outbound-api" + s.To = r.FormValue("To") + s.From = r.FormValue("From") + s.StatusURL = r.FormValue("StatusCallback") + s.CallURL = r.FormValue("Url") + + if s.CallURL == "" { + respondErr(w, twError{ + Status: 400, + Code: 21205, + Message: "Url parameter is required.", + }) + return + } + + if s.To == "" { + respondErr(w, twError{ + Status: 400, + Code: 21201, + Message: "No 'To' number is specified", + }) + return + } + + if s.From == "" { + respondErr(w, twError{ + Status: 400, + Code: 21213, + Message: "No 'From' number is specified", + }) + return + } + + n := srv.number(s.From) + if n == nil { + respondErr(w, twError{ + Status: 400, + Code: 21210, + Message: fmt.Sprintf("The source phone number provided, %s, is not yet verified for your account. You may only make calls from phone numbers that you've verified or purchased from Twilio.", s.From), + }) + return + } + + if srv.number(s.To) != nil { + // TODO: what's the correct approach here? + http.Error(w, "app to app calls not implemented", http.StatusBadRequest) + return + } + + _, err := libphonenumber.Parse(s.To, "") + if err != nil { + respondErr(w, twError{ + Status: 400, + Code: 13223, + Message: fmt.Sprintf("The phone number you are attempting to call, %s, is not valid.", s.To), + }) + return + } + + if s.StatusURL != "" && !isValidURL(s.StatusURL) { + respondErr(w, twError{ + Status: 400, + Code: 21609, + Message: fmt.Sprintf("The StatusCallback URL %s is not a valid URL.", s.StatusURL), + }) + return + } + if !isValidURL(s.CallURL) { + respondErr(w, twError{ + Status: 400, + Code: 21205, + Message: fmt.Sprintf("Url is not a valid URL: %s", s.CallURL), + }) + } + + // Note: There is an inherent race condition where the first status update can be fired + // before the original request returns, from the application's perspective, since there's + // no way on the Twilio side to know if the application is ready for a status update. + + // marshal the return value before any status changes to ensure consistent return value + data, err := json.Marshal(s) + if err != nil { + panic(err) + } + + db := <-srv.callStateDB + db[s.ID] = s + srv.callStateDB <- db + + srv.outboundCallCh <- s + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write(data) +} diff --git a/devtools/mocktwilio/handlenewmessage.go b/devtools/mocktwilio/handlenewmessage.go index 558e9e3d1d..ec6c6ab339 100644 --- a/devtools/mocktwilio/handlenewmessage.go +++ b/devtools/mocktwilio/handlenewmessage.go @@ -8,7 +8,7 @@ import ( "strings" ) -// HandleNewMessage handles POST requests to /Accounts//Messages.json +// HandleNewMessage handles POST requests to /2010-04-01/Accounts//Messages.json func (srv *Server) HandleNewMessage(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { respondErr(w, twError{ diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index bd572ec049..2910ff42ca 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -59,7 +59,7 @@ type Server struct { msgStateDB chan map[string]*msgState outboundMsgCh chan *msgState - callsCh chan Call + callCh chan Call callStateDB chan map[string]*callState outboundCallCh chan *callState @@ -110,7 +110,7 @@ func NewServer(cfg Config) *Server { msgStateDB: make(chan map[string]*msgState, 1), outboundMsgCh: make(chan *msgState), - callsCh: make(chan Call, 10000), + callCh: make(chan Call, 10000), callStateDB: make(chan map[string]*callState, 1), outboundCallCh: make(chan *callState), @@ -250,6 +250,7 @@ func (srv *Server) loop() { defer close(srv.shutdownDone) defer close(srv.msgCh) + defer close(srv.callCh) defer wg.Wait() ctx, cancel := context.WithCancel(context.Background()) @@ -258,7 +259,6 @@ func (srv *Server) loop() { for { select { case <-srv.shutdown: - return case sms := <-srv.outboundMsgCh: wg.Add(1) @@ -266,6 +266,12 @@ func (srv *Server) loop() { sms.lifecycle(ctx) wg.Done() }() + case call := <-srv.outboundCallCh: + wg.Add(1) + go func() { + call.lifecycle(ctx) + wg.Done() + }() case ch := <-srv.waitInFlight: go func() { wg.Wait() diff --git a/devtools/mocktwilio/server_test.go b/devtools/mocktwilio/server_test.go index 69ac9877f1..f52149c880 100644 --- a/devtools/mocktwilio/server_test.go +++ b/devtools/mocktwilio/server_test.go @@ -3,7 +3,6 @@ package mocktwilio_test import ( "context" "encoding/json" - "io/ioutil" "net/http" "net/http/httptest" "net/url" @@ -15,79 +14,116 @@ import ( ) func TestServer(t *testing.T) { - cfg := mocktwilio.Config{ - AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - AuthToken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - OnError: func(ctx context.Context, err error) { - t.Errorf("mocktwilio: error: %v", err) - }, - } + t.Run("SMS", func(t *testing.T) { + cfg := mocktwilio.Config{ + AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + AuthToken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + OnError: func(ctx context.Context, err error) { + t.Errorf("mocktwilio: error: %v", err) + }, + } - mux := http.NewServeMux() - mux.HandleFunc("/voice", func(w http.ResponseWriter, req *http.Request) { - t.Error("mocktwilio: unexpected voice request") - w.WriteHeader(204) - }) - mux.HandleFunc("/sms", func(w http.ResponseWriter, req *http.Request) { - assert.Equal(t, "hello", req.FormValue("Body")) - w.WriteHeader(204) - }) - mux.HandleFunc("/status", func(w http.ResponseWriter, req *http.Request) { - t.Log(req.URL.Path) - w.WriteHeader(204) - }) - appHTTP := httptest.NewServer(mux) - defer appHTTP.Close() + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + t.Errorf("mocktwilio: unexpected request to %s", req.URL.String()) + w.WriteHeader(204) + }) + mux.HandleFunc("/sms", func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "hello", req.FormValue("Body")) + w.WriteHeader(204) + }) + mux.HandleFunc("/status", func(w http.ResponseWriter, req *http.Request) { + t.Log(req.URL.Path) + w.WriteHeader(204) + }) + appHTTP := httptest.NewServer(mux) + srv := mocktwilio.NewServer(cfg) + twHTTP := httptest.NewServer(srv) + defer appHTTP.Close() + defer srv.Close() + defer twHTTP.Close() - srv := mocktwilio.NewServer(cfg) - twHTTP := httptest.NewServer(srv) - defer twHTTP.Close() + appPhone := mocktwilio.Number{ + Number: mocktwilio.NewPhoneNumber(), + VoiceWebhookURL: appHTTP.URL + "/voice", + SMSWebhookURL: appHTTP.URL + "/sms", + } + require.NoError(t, srv.AddNumber(appPhone)) - appPhone := mocktwilio.Number{ - Number: mocktwilio.NewPhoneNumber(), - VoiceWebhookURL: appHTTP.URL + "/voice", - SMSWebhookURL: appHTTP.URL + "/sms", - } - err := srv.AddNumber(appPhone) - require.NoError(t, err) + // send device to app + devNum := mocktwilio.NewPhoneNumber() + _, err := srv.SendMessage(context.Background(), devNum, appPhone.Number, "hello") + require.NoError(t, err) - // send device to app - devNum := mocktwilio.NewPhoneNumber() - _, err = srv.SendMessage(context.Background(), devNum, appPhone.Number, "hello") - require.NoError(t, err) + // send app to device + v := make(url.Values) + v.Set("From", appPhone.Number) + v.Set("To", devNum) + v.Set("Body", "world") + v.Set("StatusCallback", appHTTP.URL+"/status") + resp, err := http.PostForm(twHTTP.URL+"/2010-04-01/Accounts/"+cfg.AccountSID+"/Messages.json", v) + require.NoError(t, err) + var msgStatus struct { + SID string + Status string + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&msgStatus)) + assert.Equal(t, "queued", msgStatus.Status) - // send app to device - v := make(url.Values) - v.Set("From", appPhone.Number) - v.Set("To", devNum) - v.Set("Body", "world") - v.Set("StatusCallback", appHTTP.URL+"/status") - resp, err := http.PostForm(twHTTP.URL+"/2010-04-01/Accounts/"+cfg.AccountSID+"/Messages.json", v) - require.NoError(t, err) + msg := <-srv.Messages() + assert.Equal(t, msg.ID(), msgStatus.SID) - data, err := ioutil.ReadAll(resp.Body) - require.NoError(t, err) - t.Log("Response:", string(data)) - require.Equal(t, 201, resp.StatusCode) + resp, err = http.Get(twHTTP.URL + "/2010-04-01/Accounts/" + cfg.AccountSID + "/Messages/" + msg.ID() + ".json") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&msgStatus)) + require.Equal(t, "sending", msgStatus.Status) - require.NoError(t, err) - var res struct { - SID string - } - err = json.Unmarshal(data, &res) - require.NoError(t, err) + require.NoError(t, msg.SetStatus(context.Background(), mocktwilio.MessageDelivered)) - msg := <-srv.Messages() - assert.Equal(t, res.SID, msg.ID()) + resp, err = http.Get(twHTTP.URL + "/2010-04-01/Accounts/" + cfg.AccountSID + "/Messages/" + msg.ID() + ".json") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&msgStatus)) + require.Equal(t, "delivered", msgStatus.Status) - resp, err = http.Get(twHTTP.URL + "/2010-04-01/Accounts/" + cfg.AccountSID + "/Messages/" + res.SID + ".json") - require.NoError(t, err) + t.Fail() + require.NoError(t, srv.Close()) + }) + + t.Run("Voice", func(t *testing.T) { + return + cfg := mocktwilio.Config{ + AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + AuthToken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + OnError: func(ctx context.Context, err error) { + t.Errorf("mocktwilio: error: %v", err) + }, + } - data, err = ioutil.ReadAll(resp.Body) - require.NoError(t, err) - t.Log("Response:", string(data)) - require.Equal(t, 200, resp.StatusCode) + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + t.Errorf("mocktwilio: unexpected request to %s", req.URL.String()) + w.WriteHeader(204) + }) + mux.HandleFunc("/sms", func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "hello", req.FormValue("Body")) + w.WriteHeader(204) + }) + mux.HandleFunc("/status", func(w http.ResponseWriter, req *http.Request) { + t.Log(req.URL.Path) + w.WriteHeader(204) + }) + appHTTP := httptest.NewServer(mux) + srv := mocktwilio.NewServer(cfg) + twHTTP := httptest.NewServer(srv) + defer appHTTP.Close() + defer srv.Close() + defer twHTTP.Close() - err = srv.Close() - require.NoError(t, err) + appPhone := mocktwilio.Number{ + Number: mocktwilio.NewPhoneNumber(), + VoiceWebhookURL: appHTTP.URL + "/voice", + SMSWebhookURL: appHTTP.URL + "/sms", + } + require.NoError(t, srv.AddNumber(appPhone)) + }) } diff --git a/devtools/mocktwilio/strings.go b/devtools/mocktwilio/strings.go index dc55986d17..9696042b50 100644 --- a/devtools/mocktwilio/strings.go +++ b/devtools/mocktwilio/strings.go @@ -1,6 +1,18 @@ package mocktwilio -import "strings" +import ( + "net/url" + "strings" +) + +// isValidURL checks if a string is a valid http or https URL. +func isValidURL(urlStr string) bool { + u, err := url.Parse(urlStr) + if err != nil { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} func toLowerSlice(s []string) []string { for i, a := range s { From a854a180e8b404186fd9f5ada5a173e01dc2150a Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 23 Aug 2022 14:52:44 -0500 Subject: [PATCH 17/46] post call status updates --- devtools/mocktwilio/callstate.go | 96 +++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/devtools/mocktwilio/callstate.go b/devtools/mocktwilio/callstate.go index c9a7ab84d1..e6a748a1ef 100644 --- a/devtools/mocktwilio/callstate.go +++ b/devtools/mocktwilio/callstate.go @@ -3,9 +3,12 @@ package mocktwilio import ( "context" "encoding/json" + "encoding/xml" "fmt" + "math" "net/url" "strconv" + "strings" "sync" "time" ) @@ -28,9 +31,13 @@ type callState struct { srv *Server mx sync.Mutex + seq int - action chan struct{} - lastResp string + action chan struct{} + + lastResp struct { + Response Node + } } func (srv *Server) newCallState() *callState { @@ -44,10 +51,39 @@ func (srv *Server) newCallState() *callState { } } +func (s *callState) lifecycle(ctx context.Context) { + s.setStatus(ctx, "initiated") + s.setStatus(ctx, "ringing") + + select { + case <-ctx.Done(): + case s.srv.callCh <- &call{s}: + } +} + func (s *callState) Text() string { s.mx.Lock() defer s.mx.Unlock() - return s.lastResp + + var text strings.Builder +build: + for _, n := range s.lastResp.Response.Nodes { + switch n.XMLName.Local { + case "Say": + text.WriteString(n.Content + "\n") + case "Gather", "Hangup", "Redirect", "Reject": + break build + } + } + + return text.String() +} + +func (s *callState) Response() Node { + s.mx.Lock() + defer s.mx.Unlock() + + return s.lastResp.Response } func (s *callState) IsActive() bool { @@ -79,6 +115,13 @@ func (s *callState) Answer(ctx context.Context) error { return nil } +type Node struct { + XMLName xml.Name + Attrs []xml.Attr `xml:",any,attr"` + Content string `xml:",innerxml"` + Nodes []Node `xml:",any"` +} + func (s *callState) update(ctx context.Context, digits string) error { v := make(url.Values) v.Set("AccountSid", s.srv.cfg.AccountSID) @@ -88,6 +131,19 @@ func (s *callState) update(ctx context.Context, digits string) error { v.Set("Direction", s.Direction) v.Set("From", s.From) v.Set("To", s.To) + if digits != "" { + v.Set("Digits", digits) + } + + data, err := s.srv.post(ctx, s.CallURL, v) + if err != nil { + return err + } + + err = xml.Unmarshal(data, &s.lastResp) + if err != nil { + return fmt.Errorf("unmarshal app response: %v", err) + } return nil } @@ -98,12 +154,13 @@ func (s *callState) status() string { return s.Status } -func (s *callState) lifecycle(ctx context.Context) { -} - func (s *callState) setStatus(ctx context.Context, status string) { s.mx.Lock() defer s.mx.Unlock() + if s.Status == status { + return + } + s.Status = status s.UpdatedAt = time.Now() switch status { @@ -116,7 +173,32 @@ func (s *callState) setStatus(ctx context.Context, status string) { } } - // TODO: post status, pass to logErr + if s.StatusURL == "" { + return + } + + v := make(url.Values) + v.Set("AccountSid", s.srv.cfg.AccountSID) + v.Set("ApiVersion", "2010-04-01") + if s.StartedAt != nil { + dur := time.Since(*s.StartedAt) + if s.EndedAt != nil { + dur = s.EndedAt.Sub(*s.StartedAt) + } + v.Set("Duration", strconv.Itoa(int(math.Ceil(dur.Seconds())))) + } + v.Set("CallSid", s.ID) + v.Set("CallStatus", status) + v.Set("Direction", s.Direction) + v.Set("From", s.From) + v.Set("To", s.To) + s.seq++ + v.Set("SequenceNumber", strconv.Itoa(s.seq)) + + _, err := s.srv.post(ctx, s.StatusURL, v) + if err != nil { + s.srv.logErr(ctx, err) + } } func (s *callState) Press(ctx context.Context, key string) error { From 86a7433b9d5cf335f9d6eda726854a7e9da5b8db Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Tue, 23 Aug 2022 19:29:35 -0500 Subject: [PATCH 18/46] twiml package --- devtools/mocktwilio/twiml/nullint.go | 41 ++++ devtools/mocktwilio/twiml/nullint_test.go | 32 +++ devtools/mocktwilio/twiml/verbs.go | 275 ++++++++++++++++++++++ devtools/mocktwilio/twiml/verbs_test.go | 60 +++++ 4 files changed, 408 insertions(+) create mode 100644 devtools/mocktwilio/twiml/nullint.go create mode 100644 devtools/mocktwilio/twiml/nullint_test.go create mode 100644 devtools/mocktwilio/twiml/verbs.go create mode 100644 devtools/mocktwilio/twiml/verbs_test.go diff --git a/devtools/mocktwilio/twiml/nullint.go b/devtools/mocktwilio/twiml/nullint.go new file mode 100644 index 0000000000..68a38ceb5b --- /dev/null +++ b/devtools/mocktwilio/twiml/nullint.go @@ -0,0 +1,41 @@ +package twiml + +import ( + "encoding/xml" + "strconv" +) + +type NullInt struct { + Valid bool + Value int +} + +var ( + _ xml.MarshalerAttr = (*NullInt)(nil) + _ xml.UnmarshalerAttr = (*NullInt)(nil) +) + +func (n *NullInt) UnmarshalXMLAttr(attr xml.Attr) error { + if attr.Value == "" { + n.Valid = false + n.Value = 0 + return nil + } + + val, err := strconv.Atoi(attr.Value) + if err != nil { + return err + } + + n.Valid = true + n.Value = val + return nil +} + +func (n *NullInt) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { + if n == nil || !n.Valid { + return xml.Attr{}, nil + } + + return xml.Attr{Name: name, Value: strconv.Itoa(n.Value)}, nil +} diff --git a/devtools/mocktwilio/twiml/nullint_test.go b/devtools/mocktwilio/twiml/nullint_test.go new file mode 100644 index 0000000000..89de97b198 --- /dev/null +++ b/devtools/mocktwilio/twiml/nullint_test.go @@ -0,0 +1,32 @@ +package twiml + +import ( + "encoding/xml" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNullInt(t *testing.T) { + type testDoc struct { + XMLName xml.Name `xml:"Test"` + HasValue NullInt `xml:",attr"` + HasValue2 NullInt `xml:",attr"` + NoValue NullInt `xml:",attr"` + } + var doc testDoc + doc.HasValue = NullInt{Valid: true} + doc.HasValue2 = NullInt{Valid: true, Value: 2} + + data, err := xml.Marshal(&doc) + assert.NoError(t, err) + assert.Equal(t, ``, string(data)) + + var doc2 testDoc + err = xml.Unmarshal(data, &doc2) + assert.NoError(t, err) + assert.True(t, doc2.HasValue.Valid) + assert.NotNil(t, doc2.HasValue2) + assert.False(t, doc2.NoValue.Valid) + assert.Equal(t, 2, doc2.HasValue2.Value) +} diff --git a/devtools/mocktwilio/twiml/verbs.go b/devtools/mocktwilio/twiml/verbs.go new file mode 100644 index 0000000000..6e98d9d13f --- /dev/null +++ b/devtools/mocktwilio/twiml/verbs.go @@ -0,0 +1,275 @@ +package twiml + +import ( + "encoding/xml" + "strconv" + "time" +) + +type Gather struct { + // Action defaults to the current request URL and can be relative or absolute. + Action string `xml:"action,attr,omitempty"` + + // FinishOnKey defaults to "#". + FinishOnKey string `xml:"finishOnKey,attr,omitempty"` + + Hints string `xml:"hints,attr,omitempty"` + + // Input defaults to dtmf. + Input string `xml:"input,attr,omitempty"` + + // Language defaults to en-US. + Language string `xml:"language,attr,omitempty"` + + // Method defaults to POST. + Method string `xml:"method,attr,omitempty"` + + // NumDigitsCount is the normalized version of `numDigits`. A zero value indicates no limit and is the default. + NumDigitsCount int `xml:"-"` + + // PartialResultCallback defaults to the current request URL and can be relative or absolute. + // + // https://www.twilio.com/docs/voice/twiml/gather#partialresultcallback + PartialResultCallback string `xml:"partialResultCallback,attr"` + + PartialResultCallbackMethod string `xml:"partialResultCallbackMethod,attr"` + + // ProfanityFilter defaults to true. + ProfanityFilter bool `xml:"profanityFilter,attr"` + + // TimeoutDur defaults to 5 seconds. + TimeoutDur time.Duration `xml:"-"` + + // SpeechTimeoutDur is the speechTimeout attribute. + // + // It should be ignored if SpeechTimeoutAuto is true. Defaults to TimeoutDur. + SpeechTimeoutDur time.Duration `xml:"-"` + + // SpeechTimeoutAuto will be ture if the `speechTimeout` value is set to true. + SpeechTimeoutAuto bool `xml:"-"` + + SpeechModel string `xml:"speechModel,attr,omitempty"` + + Enhanced bool `xml:"enhanced,attr,omitempty"` + + ActionOnEmptyResult bool `xml:"actionOnEmptyResult,attr,omitempty"` +} + +func defStr(s *string, defaultValue string) { + if *s == "" { + *s = defaultValue + } +} + +func (g *Gather) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + type RawGather Gather + var gg struct { + RawGather + NumDigits NullInt `xml:"numDigits,attr"` + SpeechTimeout string `xml:"speechTimeout,attr"` + Timeout NullInt `xml:"timeout,attr"` + ProfanityFilter *bool `xml:"profanityFilter,attr"` + } + + if err := d.DecodeElement(&gg, &start); err != nil { + return err + } + *g = Gather(gg.RawGather) + defStr(&g.FinishOnKey, "#") + defStr(&g.Method, "POST") + defStr(&g.Input, "dtmf") + defStr(&g.Language, "en-US") + defStr(&g.PartialResultCallbackMethod, "POST") + defStr(&g.SpeechModel, "default") + if gg.NumDigits.Valid { + g.NumDigitsCount = gg.NumDigits.Value + if g.NumDigitsCount < 1 { + g.NumDigitsCount = 1 + } + } + if gg.ProfanityFilter != nil { + g.ProfanityFilter = *gg.ProfanityFilter + } else { + g.ProfanityFilter = true + } + if gg.Timeout.Valid { + g.TimeoutDur = time.Duration(gg.Timeout.Value) * time.Second + } else { + g.TimeoutDur = 5 * time.Second + } + switch gg.SpeechTimeout { + case "auto": + g.SpeechTimeoutAuto = true + case "": + g.SpeechTimeoutDur = g.TimeoutDur + default: + v, err := strconv.Atoi(gg.SpeechTimeout) + if err != nil { + return err + } + g.SpeechTimeoutDur = time.Duration(v) * time.Second + } + return nil +} + +func (g Gather) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + type RawGather Gather + var gg struct { + RawGather + NumDigits int `xml:"numDigits,attr,omitempty"` + SpeechTimeout string `xml:"speechTimeout,attr"` + Timeout int `xml:"timeout,attr"` + } + if g.NumDigitsCount > 1 { + gg.NumDigits = g.NumDigitsCount + } + if g.SpeechTimeoutAuto { + gg.SpeechTimeout = "auto" + } else { + gg.SpeechTimeout = strconv.Itoa(int(g.SpeechTimeoutDur / time.Second)) + } + gg.Timeout = int(g.TimeoutDur / time.Second) + gg.RawGather = RawGather(g) + return e.EncodeElement(gg, start) +} + +type Hangup struct{} + +// The Pause verb waits silently for a specific number of seconds. +// +// If Pause is the first verb in a TwiML document, Twilio will wait the specified number of seconds before picking up the call. +// https://www.twilio.com/docs/voice/twiml/pause +type Pause struct { + // Dur derives from the `length` attribute and indicates the length of the pause. + // + // https://www.twilio.com/docs/voice/twiml/pause#attributes-length + Dur time.Duration `xml:"-"` +} + +func (p *Pause) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + type PauseRaw Pause + var pp struct { + PauseRaw + Length NullInt `xml:"length,attr"` + } + if err := d.DecodeElement(&pp, &start); err != nil { + return err + } + *p = Pause(pp.PauseRaw) + if pp.Length.Valid { + p.Dur = time.Duration(pp.Length.Value) * time.Second + } else { + p.Dur = time.Second + } + return nil +} + +func (p Pause) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + type PauseRaw Pause + var pp struct { + PauseRaw + Length *int `xml:"length,attr"` + } + sec := int(p.Dur / time.Second) + pp.Length = &sec + pp.PauseRaw = PauseRaw(p) + return e.EncodeElement(pp, start) +} + +type Redirect struct { + // Method defaults to POST. + // + // https://www.twilio.com/docs/voice/twiml/redirect#attributes-method + Method string `xml:"method,attr,omitempty"` + + // URL is the URL to redirect to. + URL string `xml:",chardata"` +} + +func (r *Redirect) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + if err := d.DecodeElement(&r, &start); err != nil { + return err + } + if r.Method == "" { + r.Method = "POST" + } + return nil +} + +type Reject struct { + // Reason defaults to 'rejected'. + // + // https://www.twilio.com/docs/voice/twiml/reject#attributes-reason + Reason string `xml:"reason,attr,omitempty"` +} + +type RejectReason int + +const ( + RejectReasonRejected RejectReason = iota + RejectReasonBusy +) + +func (r *Reject) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + if err := d.DecodeElement(&r, &start); err != nil { + return err + } + if r.Reason == "" { + r.Reason = "rejected" + } + + return nil +} + +type Say struct { + Content string `xml:",innerxml"` + Language string `xml:"language,attr,omitempty"` + Voice string `xml:"voice,attr,omitempty"` + + // LoopCount is derived from the `loop` attribute, taking into account the default value + // and the special value of 0. + // + // If LoopCount is below zero, the message is not played. + // + // https://www.twilio.com/docs/voice/twiml/say#attributes-loop + LoopCount int `xml:"-"` +} + +var ( + _ xml.Unmarshaler = (*Say)(nil) + _ xml.Marshaler = Say{} +) + +func (s *Say) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + type SayRaw Say + var ss struct { + SayRaw + Loop NullInt `xml:"loop,attr"` + } + if err := d.DecodeElement(&ss, &start); err != nil { + return err + } + *s = Say(ss.SayRaw) + if ss.Loop.Valid { + s.LoopCount = ss.Loop.Value + if s.LoopCount == 0 { + s.LoopCount = 1000 + } + } + return nil +} + +func (s Say) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + type SayRaw Say + var ss struct { + SayRaw + Content string `xml:",innerxml"` + Loop *int `xml:"loop,attr"` + } + ss.Content = s.Content + if s.LoopCount != 0 { + ss.Loop = &s.LoopCount + } + ss.SayRaw = SayRaw(s) + return e.EncodeElement(ss, start) +} diff --git a/devtools/mocktwilio/twiml/verbs_test.go b/devtools/mocktwilio/twiml/verbs_test.go new file mode 100644 index 0000000000..d98fa38a71 --- /dev/null +++ b/devtools/mocktwilio/twiml/verbs_test.go @@ -0,0 +1,60 @@ +package twiml + +import ( + "encoding/xml" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSay(t *testing.T) { + checkExp := func(exp Say, doc, expDoc string) { + t.Helper() + var s Say + err := xml.Unmarshal([]byte(doc), &s) + require.NoError(t, err) + assert.Equal(t, exp, s) + + data, err := xml.Marshal(s) + require.NoError(t, err) + assert.Equal(t, expDoc, string(data)) + } + check := func(exp Say, doc string) { + t.Helper() + checkExp(exp, doc, doc) + } + + check(Say{Content: "hi"}, `hi`) + return + checkExp(Say{Content: "hi"}, `hi`, `hi`) + checkExp(Say{Content: "hi", LoopCount: 1000}, `hi`, `hi`) + check(Say{Content: "hi", LoopCount: 1}, `hi`) + check(Say{Content: "hi", LoopCount: 1000}, `hi`) + check(Say{Content: "hi", Voice: "foo"}, `hi`) +} + +func TestPause(t *testing.T) { + checkExp := func(exp Pause, doc, expDoc string) { + t.Helper() + var s Pause + err := xml.Unmarshal([]byte(doc), &s) + require.NoError(t, err) + assert.Equal(t, exp, s) + + data, err := xml.Marshal(s) + require.NoError(t, err) + assert.Equal(t, expDoc, string(data)) + } + check := func(exp Pause, doc string) { + t.Helper() + checkExp(exp, doc, doc) + } + + checkExp(Pause{Dur: time.Second}, ``, ``) + checkExp(Pause{Dur: time.Second}, ``, ``) + check(Pause{Dur: 2 * time.Second}, ``) + check(Pause{Dur: time.Second}, ``) + check(Pause{}, ``) +} From 7d4e1d4f9e30ed44673f50bac36505fed77262aa Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 25 Aug 2022 16:14:11 -0500 Subject: [PATCH 19/46] add verbs --- devtools/mocktwilio/twiml/anyverb.go | 71 ++++++++ devtools/mocktwilio/twiml/gather.go | 185 +++++++++++++++++++++ devtools/mocktwilio/twiml/response.go | 53 ++++++ devtools/mocktwilio/twiml/response_test.go | 40 +++++ devtools/mocktwilio/twiml/verbs.go | 130 +-------------- devtools/mocktwilio/twiml/verbs_test.go | 1 - 6 files changed, 350 insertions(+), 130 deletions(-) create mode 100644 devtools/mocktwilio/twiml/anyverb.go create mode 100644 devtools/mocktwilio/twiml/gather.go create mode 100644 devtools/mocktwilio/twiml/response.go create mode 100644 devtools/mocktwilio/twiml/response_test.go diff --git a/devtools/mocktwilio/twiml/anyverb.go b/devtools/mocktwilio/twiml/anyverb.go new file mode 100644 index 0000000000..b8b7aff3fa --- /dev/null +++ b/devtools/mocktwilio/twiml/anyverb.go @@ -0,0 +1,71 @@ +package twiml + +import ( + "encoding/xml" + "errors" + "fmt" +) + +type anyVerb struct { + verb any +} + +func decodeVerb(d *xml.Decoder, start xml.StartElement) (any, error) { + switch start.Name.Local { + case "Gather": + g := new(Gather) + return *g, d.DecodeElement(&g, &start) + case "Hangup": + h := new(Hangup) + return *h, d.DecodeElement(&h, &start) + case "Pause": + p := new(Pause) + return *p, d.DecodeElement(&p, &start) + case "Redirect": + r := new(Redirect) + return *r, d.DecodeElement(&r, &start) + case "Reject": + re := new(Reject) + return *re, d.DecodeElement(&re, &start) + case "Say": + s := new(Say) + return *s, d.DecodeElement(&s, &start) + } + + return nil, errors.New("unsupported verb: " + start.Name.Local) +} + +func encodeVerb(e *xml.Encoder, v any) error { + var name string + switch v.(type) { + case Gather: + name = "Gather" + case Hangup: + name = "Hangup" + case Pause: + name = "Pause" + case Redirect: + name = "Redirect" + case Reject: + name = "Reject" + case Say: + name = "Say" + default: + return fmt.Errorf("unsupported verb: %T", v) + } + + if err := e.EncodeElement(v, xml.StartElement{Name: xml.Name{Local: name}}); err != nil { + return err + } + + return nil +} + +func (v *anyVerb) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) { + v.verb, err = decodeVerb(d, start) + return err +} + +func (v anyVerb) MarshalXML(e *xml.Encoder, start xml.StartElement) (err error) { + return encodeVerb(e, v.verb) +} diff --git a/devtools/mocktwilio/twiml/gather.go b/devtools/mocktwilio/twiml/gather.go new file mode 100644 index 0000000000..d69dd75608 --- /dev/null +++ b/devtools/mocktwilio/twiml/gather.go @@ -0,0 +1,185 @@ +package twiml + +import ( + "encoding/xml" + "fmt" + "strconv" + "time" +) + +type Gather struct { + XMLName xml.Name `xml:"Gather"` + + // Action defaults to the current request URL and can be relative or absolute. + Action string `xml:"action,attr,omitempty"` + + // FinishOnKey defaults to "#". + FinishOnKey string `xml:"finishOnKey,attr,omitempty"` + + Hints string `xml:"hints,attr,omitempty"` + + // Input defaults to dtmf. + Input string `xml:"input,attr,omitempty"` + + // Language defaults to en-US. + Language string `xml:"language,attr,omitempty"` + + // Method defaults to POST. + Method string `xml:"method,attr,omitempty"` + + // NumDigitsCount is the normalized version of `numDigits`. A zero value indicates no limit and is the default. + NumDigitsCount int `xml:"-"` + + // PartialResultCallback defaults to the current request URL and can be relative or absolute. + // + // https://www.twilio.com/docs/voice/twiml/gather#partialresultcallback + PartialResultCallback string `xml:"partialResultCallback,attr,omitempty"` + + PartialResultCallbackMethod string `xml:"partialResultCallbackMethod,attr,omitempty"` + + // DisableProfanityFilter disables the profanity filter. + DisableProfanityFilter bool `xml:"-"` + + // TimeoutDur defaults to 5 seconds. + TimeoutDur time.Duration `xml:"-"` + + // SpeechTimeoutDur is the speechTimeout attribute. + // + // It should be ignored if SpeechTimeoutAuto is true. Defaults to TimeoutDur. + SpeechTimeoutDur time.Duration `xml:"-"` + + // SpeechTimeoutAuto will be ture if the `speechTimeout` value is set to true. + SpeechTimeoutAuto bool `xml:"-"` + + SpeechModel string `xml:"speechModel,attr,omitempty"` + + Enhanced bool `xml:"enhanced,attr,omitempty"` + + ActionOnEmptyResult bool `xml:"actionOnEmptyResult,attr,omitempty"` + + Verbs []any `xml:"-"` +} + +func defStr(s *string, defaultValue string) { + if *s == "" { + *s = defaultValue + } +} + +func (g *Gather) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + type RawGather Gather + var gg struct { + RawGather + NumDigits NullInt `xml:"numDigits,attr"` + SpeechTimeout string `xml:"speechTimeout,attr"` + Timeout NullInt `xml:"timeout,attr"` + ProfanityFilter *bool `xml:"profanityFilter,attr"` + + Content []anyVerb `xml:",any"` + } + + if err := d.DecodeElement(&gg, &start); err != nil { + return err + } + *g = Gather(gg.RawGather) + for _, v := range gg.Content { + switch v.verb.(type) { + case Say: + g.Verbs = append(g.Verbs, v.verb) + case Pause: + g.Verbs = append(g.Verbs, v.verb) + default: + return fmt.Errorf("unexpected verb in Gather: %T", v.verb) + } + } + + defStr(&g.FinishOnKey, "#") + defStr(&g.Method, "POST") + defStr(&g.Input, "dtmf") + defStr(&g.Language, "en-US") + defStr(&g.PartialResultCallbackMethod, "POST") + defStr(&g.SpeechModel, "default") + if gg.NumDigits.Valid { + g.NumDigitsCount = gg.NumDigits.Value + if g.NumDigitsCount < 1 { + g.NumDigitsCount = 1 + } + } + if gg.ProfanityFilter != nil { + g.DisableProfanityFilter = !*gg.ProfanityFilter + } + if gg.Timeout.Valid { + g.TimeoutDur = time.Duration(gg.Timeout.Value) * time.Second + } else { + g.TimeoutDur = 5 * time.Second + } + switch gg.SpeechTimeout { + case "auto": + g.SpeechTimeoutAuto = true + case "": + g.SpeechTimeoutDur = g.TimeoutDur + default: + v, err := strconv.Atoi(gg.SpeechTimeout) + if err != nil { + return err + } + g.SpeechTimeoutDur = time.Duration(v) * time.Second + } + + return nil +} + +func (g Gather) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + type RawGather Gather + var gg struct { + RawGather + NumDigits int `xml:"numDigits,attr,omitempty"` + SpeechTimeout string `xml:"speechTimeout,attr,omitempty"` + Timeout int `xml:"timeout,attr,omitempty"` + ProfanityFilter *bool `xml:"profanityFilter,attr,omitempty"` + Verbs []any `xml:",any"` + } + gg.RawGather = RawGather(g) + for _, v := range g.Verbs { + switch t := v.(type) { + case Say: + gg.Verbs = append(gg.Verbs, anyVerb{verb: t}) + case Pause: + gg.Verbs = append(gg.Verbs, anyVerb{verb: t}) + default: + return fmt.Errorf("unexpected verb in Gather: %T", v) + } + } + if g.NumDigitsCount > 1 { + gg.NumDigits = g.NumDigitsCount + } + if g.SpeechTimeoutAuto { + gg.SpeechTimeout = "auto" + } else if g.SpeechTimeoutDur != gg.TimeoutDur { + gg.SpeechTimeout = strconv.Itoa(int(g.SpeechTimeoutDur / time.Second)) + } + if gg.Input == "dtmf" { + gg.Input = "" + } + if gg.FinishOnKey == "#" { + gg.FinishOnKey = "" + } + if gg.Language == "en-US" { + gg.Language = "" + } + if gg.PartialResultCallback == "" || gg.PartialResultCallbackMethod == "POST" { + gg.PartialResultCallbackMethod = "" + } + if gg.Method == "POST" { + gg.Method = "" + } + if g.DisableProfanityFilter { + gg.ProfanityFilter = new(bool) + *gg.ProfanityFilter = false + } + if gg.SpeechModel == "default" { + gg.SpeechModel = "" + } + gg.Timeout = int(g.TimeoutDur / time.Second) + return e.EncodeElement(gg, start) +} diff --git a/devtools/mocktwilio/twiml/response.go b/devtools/mocktwilio/twiml/response.go new file mode 100644 index 0000000000..64ba1f5d2b --- /dev/null +++ b/devtools/mocktwilio/twiml/response.go @@ -0,0 +1,53 @@ +package twiml + +import ( + "encoding/xml" + "errors" + "fmt" + "io" +) + +type Response struct { + Verbs []any `xml:"-"` +} + +func (r *Response) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + for { + t, err := d.Token() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + + return err + } + if t == nil { + break + } + + if t, ok := t.(xml.StartElement); ok { + v, err := decodeVerb(d, t) + if err != nil { + return err + } + fmt.Println(v) + r.Verbs = append(r.Verbs, v) + } + } + + return nil +} + +func (r Response) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if err := e.EncodeToken(start); err != nil { + return err + } + + for _, v := range r.Verbs { + if err := encodeVerb(e, v); err != nil { + return err + } + } + + return e.EncodeToken(start.End()) +} diff --git a/devtools/mocktwilio/twiml/response_test.go b/devtools/mocktwilio/twiml/response_test.go new file mode 100644 index 0000000000..0ef03a879a --- /dev/null +++ b/devtools/mocktwilio/twiml/response_test.go @@ -0,0 +1,40 @@ +package twiml + +import ( + "encoding/xml" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResponse_UnmarshalXML(t *testing.T) { + const doc = `hithere` + + var r Response + err := xml.Unmarshal([]byte(doc), &r) + require.NoError(t, err) + + assert.Equal(t, Response{Verbs: []any{ + Say{Content: "hi"}, + Gather{ + TimeoutDur: 13 * time.Second, + Verbs: []any{Say{Content: "there"}}, + + // defaults should be set + FinishOnKey: "#", + Input: "dtmf", + Language: "en-US", + Method: "POST", + PartialResultCallbackMethod: "POST", + SpeechTimeoutDur: 13 * time.Second, // defaults to TimeoutDur + SpeechModel: "default", + }, + Hangup{}, + }}, r) + + data, err := xml.Marshal(r) + require.NoError(t, err) + assert.Equal(t, doc, string(data)) +} diff --git a/devtools/mocktwilio/twiml/verbs.go b/devtools/mocktwilio/twiml/verbs.go index 6e98d9d13f..4de7f4e0e9 100644 --- a/devtools/mocktwilio/twiml/verbs.go +++ b/devtools/mocktwilio/twiml/verbs.go @@ -2,137 +2,9 @@ package twiml import ( "encoding/xml" - "strconv" "time" ) -type Gather struct { - // Action defaults to the current request URL and can be relative or absolute. - Action string `xml:"action,attr,omitempty"` - - // FinishOnKey defaults to "#". - FinishOnKey string `xml:"finishOnKey,attr,omitempty"` - - Hints string `xml:"hints,attr,omitempty"` - - // Input defaults to dtmf. - Input string `xml:"input,attr,omitempty"` - - // Language defaults to en-US. - Language string `xml:"language,attr,omitempty"` - - // Method defaults to POST. - Method string `xml:"method,attr,omitempty"` - - // NumDigitsCount is the normalized version of `numDigits`. A zero value indicates no limit and is the default. - NumDigitsCount int `xml:"-"` - - // PartialResultCallback defaults to the current request URL and can be relative or absolute. - // - // https://www.twilio.com/docs/voice/twiml/gather#partialresultcallback - PartialResultCallback string `xml:"partialResultCallback,attr"` - - PartialResultCallbackMethod string `xml:"partialResultCallbackMethod,attr"` - - // ProfanityFilter defaults to true. - ProfanityFilter bool `xml:"profanityFilter,attr"` - - // TimeoutDur defaults to 5 seconds. - TimeoutDur time.Duration `xml:"-"` - - // SpeechTimeoutDur is the speechTimeout attribute. - // - // It should be ignored if SpeechTimeoutAuto is true. Defaults to TimeoutDur. - SpeechTimeoutDur time.Duration `xml:"-"` - - // SpeechTimeoutAuto will be ture if the `speechTimeout` value is set to true. - SpeechTimeoutAuto bool `xml:"-"` - - SpeechModel string `xml:"speechModel,attr,omitempty"` - - Enhanced bool `xml:"enhanced,attr,omitempty"` - - ActionOnEmptyResult bool `xml:"actionOnEmptyResult,attr,omitempty"` -} - -func defStr(s *string, defaultValue string) { - if *s == "" { - *s = defaultValue - } -} - -func (g *Gather) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { - type RawGather Gather - var gg struct { - RawGather - NumDigits NullInt `xml:"numDigits,attr"` - SpeechTimeout string `xml:"speechTimeout,attr"` - Timeout NullInt `xml:"timeout,attr"` - ProfanityFilter *bool `xml:"profanityFilter,attr"` - } - - if err := d.DecodeElement(&gg, &start); err != nil { - return err - } - *g = Gather(gg.RawGather) - defStr(&g.FinishOnKey, "#") - defStr(&g.Method, "POST") - defStr(&g.Input, "dtmf") - defStr(&g.Language, "en-US") - defStr(&g.PartialResultCallbackMethod, "POST") - defStr(&g.SpeechModel, "default") - if gg.NumDigits.Valid { - g.NumDigitsCount = gg.NumDigits.Value - if g.NumDigitsCount < 1 { - g.NumDigitsCount = 1 - } - } - if gg.ProfanityFilter != nil { - g.ProfanityFilter = *gg.ProfanityFilter - } else { - g.ProfanityFilter = true - } - if gg.Timeout.Valid { - g.TimeoutDur = time.Duration(gg.Timeout.Value) * time.Second - } else { - g.TimeoutDur = 5 * time.Second - } - switch gg.SpeechTimeout { - case "auto": - g.SpeechTimeoutAuto = true - case "": - g.SpeechTimeoutDur = g.TimeoutDur - default: - v, err := strconv.Atoi(gg.SpeechTimeout) - if err != nil { - return err - } - g.SpeechTimeoutDur = time.Duration(v) * time.Second - } - return nil -} - -func (g Gather) MarshalXML(e *xml.Encoder, start xml.StartElement) error { - type RawGather Gather - var gg struct { - RawGather - NumDigits int `xml:"numDigits,attr,omitempty"` - SpeechTimeout string `xml:"speechTimeout,attr"` - Timeout int `xml:"timeout,attr"` - } - if g.NumDigitsCount > 1 { - gg.NumDigits = g.NumDigitsCount - } - if g.SpeechTimeoutAuto { - gg.SpeechTimeout = "auto" - } else { - gg.SpeechTimeout = strconv.Itoa(int(g.SpeechTimeoutDur / time.Second)) - } - gg.Timeout = int(g.TimeoutDur / time.Second) - gg.RawGather = RawGather(g) - return e.EncodeElement(gg, start) -} - type Hangup struct{} // The Pause verb waits silently for a specific number of seconds. @@ -168,7 +40,7 @@ func (p Pause) MarshalXML(e *xml.Encoder, start xml.StartElement) error { type PauseRaw Pause var pp struct { PauseRaw - Length *int `xml:"length,attr"` + Length *int `xml:"length,attr,omitempty"` } sec := int(p.Dur / time.Second) pp.Length = &sec diff --git a/devtools/mocktwilio/twiml/verbs_test.go b/devtools/mocktwilio/twiml/verbs_test.go index d98fa38a71..ee53e27f0b 100644 --- a/devtools/mocktwilio/twiml/verbs_test.go +++ b/devtools/mocktwilio/twiml/verbs_test.go @@ -27,7 +27,6 @@ func TestSay(t *testing.T) { } check(Say{Content: "hi"}, `hi`) - return checkExp(Say{Content: "hi"}, `hi`, `hi`) checkExp(Say{Content: "hi", LoopCount: 1000}, `hi`, `hi`) check(Say{Content: "hi", LoopCount: 1}, `hi`) From e876fcb136cad422f1484c6077b0a438d47e8f34 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 25 Aug 2022 18:15:20 -0500 Subject: [PATCH 20/46] add type safe interfaces --- devtools/mocktwilio/twiml/anyverb.go | 51 ++++++++++++----- devtools/mocktwilio/twiml/gather.go | 18 +++--- devtools/mocktwilio/twiml/interpreter.go | 66 ++++++++++++++++++++++ devtools/mocktwilio/twiml/response.go | 4 +- devtools/mocktwilio/twiml/response_test.go | 10 ++-- 5 files changed, 117 insertions(+), 32 deletions(-) create mode 100644 devtools/mocktwilio/twiml/interpreter.go diff --git a/devtools/mocktwilio/twiml/anyverb.go b/devtools/mocktwilio/twiml/anyverb.go index b8b7aff3fa..19657ac3e1 100644 --- a/devtools/mocktwilio/twiml/anyverb.go +++ b/devtools/mocktwilio/twiml/anyverb.go @@ -6,49 +6,70 @@ import ( "fmt" ) +type ( + v struct{} + GatherVerb interface { + Verb + isGatherVerb() v + } + Verb interface { + isVerb() v + } +) + +func (*Pause) isGatherVerb() v { return v{} } +func (*Say) isGatherVerb() v { return v{} } + +func (*Pause) isVerb() v { return v{} } +func (*Say) isVerb() v { return v{} } +func (*Gather) isVerb() v { return v{} } +func (*Hangup) isVerb() v { return v{} } +func (*Redirect) isVerb() v { return v{} } +func (*Reject) isVerb() v { return v{} } + type anyVerb struct { - verb any + verb Verb } -func decodeVerb(d *xml.Decoder, start xml.StartElement) (any, error) { +func decodeVerb(d *xml.Decoder, start xml.StartElement) (Verb, error) { switch start.Name.Local { case "Gather": g := new(Gather) - return *g, d.DecodeElement(&g, &start) + return g, d.DecodeElement(&g, &start) case "Hangup": h := new(Hangup) - return *h, d.DecodeElement(&h, &start) + return h, d.DecodeElement(&h, &start) case "Pause": p := new(Pause) - return *p, d.DecodeElement(&p, &start) + return p, d.DecodeElement(&p, &start) case "Redirect": r := new(Redirect) - return *r, d.DecodeElement(&r, &start) + return r, d.DecodeElement(&r, &start) case "Reject": re := new(Reject) - return *re, d.DecodeElement(&re, &start) + return re, d.DecodeElement(&re, &start) case "Say": s := new(Say) - return *s, d.DecodeElement(&s, &start) + return s, d.DecodeElement(&s, &start) } return nil, errors.New("unsupported verb: " + start.Name.Local) } -func encodeVerb(e *xml.Encoder, v any) error { +func encodeVerb(e *xml.Encoder, v Verb) error { var name string switch v.(type) { - case Gather: + case *Gather: name = "Gather" - case Hangup: + case *Hangup: name = "Hangup" - case Pause: + case *Pause: name = "Pause" - case Redirect: + case *Redirect: name = "Redirect" - case Reject: + case *Reject: name = "Reject" - case Say: + case *Say: name = "Say" default: return fmt.Errorf("unsupported verb: %T", v) diff --git a/devtools/mocktwilio/twiml/gather.go b/devtools/mocktwilio/twiml/gather.go index d69dd75608..756855a68b 100644 --- a/devtools/mocktwilio/twiml/gather.go +++ b/devtools/mocktwilio/twiml/gather.go @@ -57,7 +57,7 @@ type Gather struct { ActionOnEmptyResult bool `xml:"actionOnEmptyResult,attr,omitempty"` - Verbs []any `xml:"-"` + Verbs []GatherVerb `xml:"-"` } func defStr(s *string, defaultValue string) { @@ -83,13 +83,13 @@ func (g *Gather) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { } *g = Gather(gg.RawGather) for _, v := range gg.Content { - switch v.verb.(type) { - case Say: - g.Verbs = append(g.Verbs, v.verb) - case Pause: - g.Verbs = append(g.Verbs, v.verb) + switch t := v.verb.(type) { + case *Say: + g.Verbs = append(g.Verbs, t) + case *Pause: + g.Verbs = append(g.Verbs, t) default: - return fmt.Errorf("unexpected verb in Gather: %T", v.verb) + return fmt.Errorf("unexpected verb in Gather: %T", t) } } @@ -142,9 +142,9 @@ func (g Gather) MarshalXML(e *xml.Encoder, start xml.StartElement) error { gg.RawGather = RawGather(g) for _, v := range g.Verbs { switch t := v.(type) { - case Say: + case *Say: gg.Verbs = append(gg.Verbs, anyVerb{verb: t}) - case Pause: + case *Pause: gg.Verbs = append(gg.Verbs, anyVerb{verb: t}) default: return fmt.Errorf("unexpected verb in Gather: %T", v) diff --git a/devtools/mocktwilio/twiml/interpreter.go b/devtools/mocktwilio/twiml/interpreter.go new file mode 100644 index 0000000000..983cf2969b --- /dev/null +++ b/devtools/mocktwilio/twiml/interpreter.go @@ -0,0 +1,66 @@ +package twiml + +import ( + "encoding/xml" + "fmt" +) + +type Interpreter struct { + state any + + verbs []Verb +} + +func NewInterpreter() *Interpreter { + return &Interpreter{} +} + +func (i *Interpreter) SetResponse(data []byte) error { + var rsp Response + if err := xml.Unmarshal(data, &rsp); err != nil { + return err + } + + i.verbs = append(i.verbs[:0], rsp.Verbs...) + i.state = nil + return nil +} + +func (i *Interpreter) Verb() any { return i.state } + +func (i *Interpreter) Next() bool { + if len(i.verbs) == 0 { + return false + } + + switch t := i.verbs[0].(type) { + case *Hangup, *Redirect, *Reject: + // end the call + i.state = t + i.verbs = nil + case *Gather: + if len(t.Verbs) == 0 { + i.state = t + i.verbs = i.verbs[1:] + break + } + + newVerbs := make([]any, 0, len(t.Verbs)+len(i.verbs)) + i.state = t.Verbs[0] + for _, v := range t.Verbs[1:] { + newVerbs = append(newVerbs, v) + } + t.Verbs = nil + newVerbs = append(newVerbs, t) + for _, v := range i.verbs[1:] { + newVerbs = append(newVerbs, v) + } + case *Pause, *Say: + i.state = t + i.verbs = i.verbs[1:] + default: + panic(fmt.Sprintf("unhandled verb: %T", t)) + } + + return true +} diff --git a/devtools/mocktwilio/twiml/response.go b/devtools/mocktwilio/twiml/response.go index 64ba1f5d2b..0d9e5b7ae2 100644 --- a/devtools/mocktwilio/twiml/response.go +++ b/devtools/mocktwilio/twiml/response.go @@ -3,12 +3,11 @@ package twiml import ( "encoding/xml" "errors" - "fmt" "io" ) type Response struct { - Verbs []any `xml:"-"` + Verbs []Verb `xml:"-"` } func (r *Response) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -30,7 +29,6 @@ func (r *Response) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { if err != nil { return err } - fmt.Println(v) r.Verbs = append(r.Verbs, v) } } diff --git a/devtools/mocktwilio/twiml/response_test.go b/devtools/mocktwilio/twiml/response_test.go index 0ef03a879a..c4352ba918 100644 --- a/devtools/mocktwilio/twiml/response_test.go +++ b/devtools/mocktwilio/twiml/response_test.go @@ -16,11 +16,11 @@ func TestResponse_UnmarshalXML(t *testing.T) { err := xml.Unmarshal([]byte(doc), &r) require.NoError(t, err) - assert.Equal(t, Response{Verbs: []any{ - Say{Content: "hi"}, - Gather{ + assert.Equal(t, Response{Verbs: []Verb{ + &Say{Content: "hi"}, + &Gather{ TimeoutDur: 13 * time.Second, - Verbs: []any{Say{Content: "there"}}, + Verbs: []GatherVerb{&Say{Content: "there"}}, // defaults should be set FinishOnKey: "#", @@ -31,7 +31,7 @@ func TestResponse_UnmarshalXML(t *testing.T) { SpeechTimeoutDur: 13 * time.Second, // defaults to TimeoutDur SpeechModel: "default", }, - Hangup{}, + &Hangup{}, }}, r) data, err := xml.Marshal(r) From f7a9f3fcfcc6d8455fbde8a336c46f51943c9a6f Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 25 Aug 2022 18:24:46 -0500 Subject: [PATCH 21/46] call state handling --- devtools/mocktwilio/assertcall.go | 8 +- devtools/mocktwilio/call.go | 19 ++-- devtools/mocktwilio/callstate.go | 108 +++++++++++++++++------ devtools/mocktwilio/http.go | 2 +- devtools/mocktwilio/server.go | 1 + devtools/mocktwilio/twiml/interpreter.go | 4 +- 6 files changed, 100 insertions(+), 42 deletions(-) diff --git a/devtools/mocktwilio/assertcall.go b/devtools/mocktwilio/assertcall.go index c994d12839..7ce29bd809 100644 --- a/devtools/mocktwilio/assertcall.go +++ b/devtools/mocktwilio/assertcall.go @@ -13,10 +13,10 @@ type assertCall struct { func (call *assertCall) Hangup() { call.t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), call.Timeout) + ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) defer cancel() - err := call.End(ctx, CallCompleted) + err := call.Call.Hangup(ctx, CallCompleted) if err != nil { call.t.Fatalf("mocktwilio: error ending call to %s: %v", call.To(), err) } @@ -25,7 +25,7 @@ func (call *assertCall) Hangup() { func (call *assertCall) ThenPress(digits string) ExpectedCall { call.t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), call.Timeout) + ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) defer cancel() err := call.Press(ctx, digits) if err != nil { @@ -61,7 +61,7 @@ func (dev *assertDev) RejectVoice(keywords ...string) { ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) defer cancel() - err := call.End(ctx, CallFailed) + err := call.Hangup(ctx, CallFailed) if err != nil { dev.t.Fatalf("mocktwilio: error ending call to %s: %v", call.To(), err) } diff --git a/devtools/mocktwilio/call.go b/devtools/mocktwilio/call.go index cd81e96f4b..b9f101357e 100644 --- a/devtools/mocktwilio/call.go +++ b/devtools/mocktwilio/call.go @@ -1,6 +1,8 @@ package mocktwilio -import "context" +import ( + "context" +) type Call interface { ID() string @@ -11,17 +13,20 @@ type Call interface { // Text will return the last message returned by the application. It is empty until Answer is called. Text() string - Answer(context.Context) error + // User interactions below - IsActive() bool + Answer(context.Context) error + Hangup(context.Context, FinalCallStatus) error - // Press will simulate a press of the specified key. + // Press will simulate a press of the specified key(s). // - // It does nothing if Answer has not been called or - // the call has ended. + // It does nothing if the call isn't waiting for input. Press(context.Context, string) error - End(context.Context, FinalCallStatus) error + // PressTimeout will simulate a user waiting for the menu to timeout. + // + // It does nothing if the call isn't waiting for input. + PressTimeout(context.Context) error } type FinalCallStatus string diff --git a/devtools/mocktwilio/callstate.go b/devtools/mocktwilio/callstate.go index e6a748a1ef..f9ebe258b2 100644 --- a/devtools/mocktwilio/callstate.go +++ b/devtools/mocktwilio/callstate.go @@ -8,9 +8,10 @@ import ( "math" "net/url" "strconv" - "strings" "sync" "time" + + "github.com/target/goalert/devtools/mocktwilio/twiml" ) type callState struct { @@ -35,9 +36,9 @@ type callState struct { action chan struct{} - lastResp struct { - Response Node - } + run *twiml.Interpreter + + text string } func (srv *Server) newCallState() *callState { @@ -46,8 +47,10 @@ func (srv *Server) newCallState() *callState { srv: srv, ID: srv.nextID("CA"), CreatedAt: n, + Status: "queued", UpdatedAt: n, action: make(chan struct{}, 1), + run: twiml.NewInterpreter(), } } @@ -64,26 +67,7 @@ func (s *callState) lifecycle(ctx context.Context) { func (s *callState) Text() string { s.mx.Lock() defer s.mx.Unlock() - - var text strings.Builder -build: - for _, n := range s.lastResp.Response.Nodes { - switch n.XMLName.Local { - case "Say": - text.WriteString(n.Content + "\n") - case "Gather", "Hangup", "Redirect", "Reject": - break build - } - } - - return text.String() -} - -func (s *callState) Response() Node { - s.mx.Lock() - defer s.mx.Unlock() - - return s.lastResp.Response + return s.text } func (s *callState) IsActive() bool { @@ -140,14 +124,45 @@ func (s *callState) update(ctx context.Context, digits string) error { return err } - err = xml.Unmarshal(data, &s.lastResp) + err = s.run.SetResponse(data) if err != nil { - return fmt.Errorf("unmarshal app response: %v", err) + return err + } + + return s.process(ctx) +} + +func (s *callState) process(ctx context.Context) error { + s.text = "" + + for s.run.Next() { + switch t := s.run.Verb().(type) { + case *twiml.Say: + s.text += t.Content + "\n" + case *twiml.Redirect: + s.setCallURL(t.URL) + return s.update(ctx, "") + case *twiml.Gather: + return nil + case *twiml.Reject: + s.setStatus(ctx, t.Reason) + return nil + case *twiml.Hangup: + s.setStatus(ctx, "completed") + return nil + case *twiml.Pause: + // ignored + } } return nil } +func (s *callState) setCallURL(url string) { + // TODO: handle relative path + s.CallURL = url +} + func (s *callState) status() string { s.mx.Lock() defer s.mx.Unlock() @@ -201,13 +216,50 @@ func (s *callState) setStatus(ctx context.Context, status string) { } } -func (s *callState) Press(ctx context.Context, key string) error { +func (s *callState) Press(ctx context.Context, digits string) error { s.action <- struct{}{} if s.status() != "in-progress" { <-s.action return fmt.Errorf("call not in progress") } + g, ok := s.run.Verb().(*twiml.Gather) + if !ok { + <-s.action + return fmt.Errorf("gather not in progress") + } + s.setCallURL(g.Action) + + err := s.update(ctx, digits) + if err != nil { + s.setStatus(ctx, "failed") + <-s.action + return err + } + + <-s.action + return nil +} + +func (s *callState) PressTimeout(ctx context.Context) error { + s.action <- struct{}{} + if s.status() != "in-progress" { + <-s.action + return fmt.Errorf("call not in progress") + } + + g, ok := s.run.Verb().(*twiml.Gather) + if !ok { + <-s.action + return fmt.Errorf("gather not in progress") + } + if !g.ActionOnEmptyResult { + err := s.process(ctx) + <-s.action + return err + } + + s.setCallURL(g.Action) err := s.update(ctx, "") if err != nil { s.setStatus(ctx, "failed") @@ -219,7 +271,7 @@ func (s *callState) Press(ctx context.Context, key string) error { return nil } -func (s *callState) End(ctx context.Context, status FinalCallStatus) error { +func (s *callState) Hangup(ctx context.Context, status FinalCallStatus) error { s.action <- struct{}{} if !s.IsActive() { <-s.action diff --git a/devtools/mocktwilio/http.go b/devtools/mocktwilio/http.go index 2dfa4b05cd..b8e81ad9e3 100644 --- a/devtools/mocktwilio/http.go +++ b/devtools/mocktwilio/http.go @@ -17,7 +17,7 @@ func (srv *Server) basePath() string { func (srv *Server) initHTTP() { srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) - // s.mux.HandleFunc(base+"/Calls.json", s.serveNewCall) + srv.mux.HandleFunc(srv.basePath()+"/Calls.json", srv.HandleNewCall) // s.mux.HandleFunc(base+"/Calls/", s.serveCallStatus) // s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) } diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index 2910ff42ca..49f2fc8671 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -122,6 +122,7 @@ func NewServer(cfg Config) *Server { srv.msgSvcDB <- make(map[string][]*Number) srv.numbersDB <- make(map[string]*Number) srv.msgStateDB <- make(map[string]*msgState) + srv.callStateDB <- make(map[string]*callState) srv.initHTTP() diff --git a/devtools/mocktwilio/twiml/interpreter.go b/devtools/mocktwilio/twiml/interpreter.go index 983cf2969b..3ccd68e4cb 100644 --- a/devtools/mocktwilio/twiml/interpreter.go +++ b/devtools/mocktwilio/twiml/interpreter.go @@ -6,7 +6,7 @@ import ( ) type Interpreter struct { - state any + state Verb verbs []Verb } @@ -26,7 +26,7 @@ func (i *Interpreter) SetResponse(data []byte) error { return nil } -func (i *Interpreter) Verb() any { return i.state } +func (i *Interpreter) Verb() Verb { return i.state } func (i *Interpreter) Next() bool { if len(i.verbs) == 0 { From 4996cde640dc67c8220e704630d1e471f3f33df2 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 25 Aug 2022 18:52:47 -0500 Subject: [PATCH 22/46] fix name conflict --- devtools/mocktwilio/assertdev.go | 2 +- devtools/mocktwilio/{assert.go => assertions.go} | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) rename devtools/mocktwilio/{assert.go => assertions.go} (88%) diff --git a/devtools/mocktwilio/assertdev.go b/devtools/mocktwilio/assertdev.go index 110a6d84be..f46702e09e 100644 --- a/devtools/mocktwilio/assertdev.go +++ b/devtools/mocktwilio/assertdev.go @@ -1,6 +1,6 @@ package mocktwilio type assertDev struct { - *assert + *assertions number string } diff --git a/devtools/mocktwilio/assert.go b/devtools/mocktwilio/assertions.go similarity index 88% rename from devtools/mocktwilio/assert.go rename to devtools/mocktwilio/assertions.go index f1da1ec3b0..4038cc19b2 100644 --- a/devtools/mocktwilio/assert.go +++ b/devtools/mocktwilio/assertions.go @@ -27,20 +27,20 @@ type ServerAPI interface { } func NewAssertions(t *testing.T, cfg AssertConfig) PhoneAssertions { - return &assert{ + return &assertions{ t: t, assertBase: &assertBase{AssertConfig: cfg}, } } -func (a *assert) WithT(t *testing.T) PhoneAssertions { - return &assert{ +func (a *assertions) WithT(t *testing.T) PhoneAssertions { + return &assertions{ t: t, assertBase: a.assertBase, } } -type assert struct { +type assertions struct { t *testing.T *assertBase } @@ -68,7 +68,7 @@ type answerer interface { Answer(context.Context) error } -func (a *assert) matchMessage(destNumber string, keywords []string, t texter) bool { +func (a *assertions) matchMessage(destNumber string, keywords []string, t texter) bool { a.t.Helper() if t.To() != destNumber { return false @@ -87,7 +87,7 @@ func (a *assert) matchMessage(destNumber string, keywords []string, t texter) bo return containsAll(t.Text(), keywords) } -func (a *assert) refresh() { +func (a *assertions) refresh() { if a.RefreshFunc == nil { return } @@ -95,11 +95,11 @@ func (a *assert) refresh() { a.RefreshFunc() } -func (a *assert) Device(number string) PhoneDevice { +func (a *assertions) Device(number string) PhoneDevice { return &assertDev{a, number} } -func (a *assert) WaitAndAssert() { +func (a *assertions) WaitAndAssert() { a.t.Helper() // flush any remaining application messages From be60927379dc0c62cee6dd01f10e7bb8f8cddce3 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 25 Aug 2022 18:52:59 -0500 Subject: [PATCH 23/46] relative url handling --- devtools/mocktwilio/callstate.go | 5 +---- devtools/mocktwilio/strings.go | 32 +++++++++++++++++++++++++++++ devtools/mocktwilio/strings_test.go | 30 +++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 devtools/mocktwilio/strings_test.go diff --git a/devtools/mocktwilio/callstate.go b/devtools/mocktwilio/callstate.go index f9ebe258b2..235476c6dd 100644 --- a/devtools/mocktwilio/callstate.go +++ b/devtools/mocktwilio/callstate.go @@ -158,10 +158,7 @@ func (s *callState) process(ctx context.Context) error { return nil } -func (s *callState) setCallURL(url string) { - // TODO: handle relative path - s.CallURL = url -} +func (s *callState) setCallURL(url string) { s.CallURL = relURL(s.CallURL, url) } func (s *callState) status() string { s.mx.Lock() diff --git a/devtools/mocktwilio/strings.go b/devtools/mocktwilio/strings.go index 9696042b50..932451a8f5 100644 --- a/devtools/mocktwilio/strings.go +++ b/devtools/mocktwilio/strings.go @@ -2,9 +2,41 @@ package mocktwilio import ( "net/url" + "path" "strings" ) +func relURL(oldURL, newURL string) string { + if newURL == "" { + return oldURL + } + + uNew, err := url.Parse(newURL) + if err != nil { + return "" + } + if uNew.Scheme != "" { + return newURL + } + uOld, err := url.Parse(oldURL) + if err != nil { + return "" + } + uOld.RawQuery = uNew.RawQuery + // use `/base` as a temporary prefix to validate the new path doesn't back up past the root (Twilio considers this an error) + if strings.HasPrefix(uNew.Path, "/") { + uOld.Path = path.Join("/base", uNew.Path) + } else { + uOld.Path = path.Join("/base", path.Dir(uOld.Path), uNew.Path) + } + if !strings.HasPrefix(uOld.Path, "/base") { + return "" + } + uOld.Path = strings.TrimPrefix(uOld.Path, "/base") + + return uOld.String() +} + // isValidURL checks if a string is a valid http or https URL. func isValidURL(urlStr string) bool { u, err := url.Parse(urlStr) diff --git a/devtools/mocktwilio/strings_test.go b/devtools/mocktwilio/strings_test.go new file mode 100644 index 0000000000..95ad45911d --- /dev/null +++ b/devtools/mocktwilio/strings_test.go @@ -0,0 +1,30 @@ +package mocktwilio + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRelURL(t *testing.T) { + check := func(oldURL, newURL, expected string) { + t.Helper() + assert.Equal(t, expected, relURL(oldURL, newURL)) + } + + // absolute + check("http://example.com/foo/bar", "http://other.example.org/bin/baz", "http://other.example.org/bin/baz") + + // relative + check("http://example.com/foo/bar", "baz", "http://example.com/foo/baz") + check("http://example.com/foo/bar", "../baz", "http://example.com/baz") + check("http://example.com/foo/bar/", "baz", "http://example.com/foo/bar/baz") + check("http://example.com/foo/bar/", "../baz", "http://example.com/foo/baz") + + // absolute path + check("http://example.com/foo/bar/", "/bin", "http://example.com/bin") + + // invalid + check("http://example.com/foo/bar/", "/../bin", "") + check("http://example.com/foo/bar", "../../../../baz", "") +} From 79747d198bfa4cfc806153affad56184d130fd78 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 25 Aug 2022 18:56:28 -0500 Subject: [PATCH 24/46] track invalid urls --- devtools/mocktwilio/callstate.go | 34 ++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/devtools/mocktwilio/callstate.go b/devtools/mocktwilio/callstate.go index 235476c6dd..487cad8bce 100644 --- a/devtools/mocktwilio/callstate.go +++ b/devtools/mocktwilio/callstate.go @@ -140,7 +140,10 @@ func (s *callState) process(ctx context.Context) error { case *twiml.Say: s.text += t.Content + "\n" case *twiml.Redirect: - s.setCallURL(t.URL) + err := s.setCallURL(t.URL) + if err != nil { + return err + } return s.update(ctx, "") case *twiml.Gather: return nil @@ -158,7 +161,15 @@ func (s *callState) process(ctx context.Context) error { return nil } -func (s *callState) setCallURL(url string) { s.CallURL = relURL(s.CallURL, url) } +func (s *callState) setCallURL(url string) error { + newURL := relURL(s.CallURL, url) + if newURL == "" { + return fmt.Errorf("invalid redirect url: %s", url) + } + + s.CallURL = newURL + return nil +} func (s *callState) status() string { s.mx.Lock() @@ -225,9 +236,14 @@ func (s *callState) Press(ctx context.Context, digits string) error { <-s.action return fmt.Errorf("gather not in progress") } - s.setCallURL(g.Action) + err := s.setCallURL(g.Action) + if err != nil { + s.setStatus(ctx, "failed") + <-s.action + return err + } - err := s.update(ctx, digits) + err = s.update(ctx, digits) if err != nil { s.setStatus(ctx, "failed") <-s.action @@ -256,8 +272,14 @@ func (s *callState) PressTimeout(ctx context.Context) error { return err } - s.setCallURL(g.Action) - err := s.update(ctx, "") + err := s.setCallURL(g.Action) + if err != nil { + s.setStatus(ctx, "failed") + <-s.action + return err + } + + err = s.update(ctx, "") if err != nil { s.setStatus(ctx, "failed") <-s.action From 12fe6eed06ea261f508d0c344d16a58922f5651a Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Thu, 25 Aug 2022 18:56:38 -0500 Subject: [PATCH 25/46] voice server test --- devtools/mocktwilio/server_test.go | 265 +++++++++++++++++------------ 1 file changed, 159 insertions(+), 106 deletions(-) diff --git a/devtools/mocktwilio/server_test.go b/devtools/mocktwilio/server_test.go index f52149c880..2389e2dfd8 100644 --- a/devtools/mocktwilio/server_test.go +++ b/devtools/mocktwilio/server_test.go @@ -3,6 +3,8 @@ package mocktwilio_test import ( "context" "encoding/json" + "encoding/xml" + "io" "net/http" "net/http/httptest" "net/url" @@ -11,119 +13,170 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/target/goalert/devtools/mocktwilio" + "github.com/target/goalert/devtools/mocktwilio/twiml" ) -func TestServer(t *testing.T) { - t.Run("SMS", func(t *testing.T) { - cfg := mocktwilio.Config{ - AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - AuthToken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - OnError: func(ctx context.Context, err error) { - t.Errorf("mocktwilio: error: %v", err) - }, - } +func TestServer_SMS(t *testing.T) { + cfg := mocktwilio.Config{ + AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + AuthToken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + OnError: func(ctx context.Context, err error) { + t.Errorf("mocktwilio: error: %v", err) + }, + } - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { - t.Errorf("mocktwilio: unexpected request to %s", req.URL.String()) - w.WriteHeader(204) - }) - mux.HandleFunc("/sms", func(w http.ResponseWriter, req *http.Request) { - assert.Equal(t, "hello", req.FormValue("Body")) - w.WriteHeader(204) - }) - mux.HandleFunc("/status", func(w http.ResponseWriter, req *http.Request) { - t.Log(req.URL.Path) - w.WriteHeader(204) - }) - appHTTP := httptest.NewServer(mux) - srv := mocktwilio.NewServer(cfg) - twHTTP := httptest.NewServer(srv) - defer appHTTP.Close() - defer srv.Close() - defer twHTTP.Close() - - appPhone := mocktwilio.Number{ - Number: mocktwilio.NewPhoneNumber(), - VoiceWebhookURL: appHTTP.URL + "/voice", - SMSWebhookURL: appHTTP.URL + "/sms", - } - require.NoError(t, srv.AddNumber(appPhone)) - - // send device to app - devNum := mocktwilio.NewPhoneNumber() - _, err := srv.SendMessage(context.Background(), devNum, appPhone.Number, "hello") - require.NoError(t, err) - - // send app to device - v := make(url.Values) - v.Set("From", appPhone.Number) - v.Set("To", devNum) - v.Set("Body", "world") - v.Set("StatusCallback", appHTTP.URL+"/status") - resp, err := http.PostForm(twHTTP.URL+"/2010-04-01/Accounts/"+cfg.AccountSID+"/Messages.json", v) - require.NoError(t, err) - var msgStatus struct { - SID string - Status string - } - require.NoError(t, json.NewDecoder(resp.Body).Decode(&msgStatus)) - assert.Equal(t, "queued", msgStatus.Status) - - msg := <-srv.Messages() - assert.Equal(t, msg.ID(), msgStatus.SID) - - resp, err = http.Get(twHTTP.URL + "/2010-04-01/Accounts/" + cfg.AccountSID + "/Messages/" + msg.ID() + ".json") - require.NoError(t, err) - require.NoError(t, json.NewDecoder(resp.Body).Decode(&msgStatus)) - require.Equal(t, "sending", msgStatus.Status) - - require.NoError(t, msg.SetStatus(context.Background(), mocktwilio.MessageDelivered)) - - resp, err = http.Get(twHTTP.URL + "/2010-04-01/Accounts/" + cfg.AccountSID + "/Messages/" + msg.ID() + ".json") - require.NoError(t, err) - require.NoError(t, json.NewDecoder(resp.Body).Decode(&msgStatus)) - require.Equal(t, "delivered", msgStatus.Status) - - t.Fail() - require.NoError(t, srv.Close()) + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + t.Errorf("mocktwilio: unexpected request to %s", req.URL.String()) + w.WriteHeader(204) }) + mux.HandleFunc("/sms", func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "hello", req.FormValue("Body")) + w.WriteHeader(204) + }) + mux.HandleFunc("/status", func(w http.ResponseWriter, req *http.Request) { + t.Log(req.URL.Path) + w.WriteHeader(204) + }) + appHTTP := httptest.NewServer(mux) + srv := mocktwilio.NewServer(cfg) + twHTTP := httptest.NewServer(srv) + defer appHTTP.Close() + defer srv.Close() + defer twHTTP.Close() - t.Run("Voice", func(t *testing.T) { - return - cfg := mocktwilio.Config{ - AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - AuthToken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - OnError: func(ctx context.Context, err error) { - t.Errorf("mocktwilio: error: %v", err) - }, - } + appPhone := mocktwilio.Number{ + Number: mocktwilio.NewPhoneNumber(), + VoiceWebhookURL: appHTTP.URL + "/voice", + SMSWebhookURL: appHTTP.URL + "/sms", + } + require.NoError(t, srv.AddNumber(appPhone)) - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { - t.Errorf("mocktwilio: unexpected request to %s", req.URL.String()) - w.WriteHeader(204) - }) - mux.HandleFunc("/sms", func(w http.ResponseWriter, req *http.Request) { - assert.Equal(t, "hello", req.FormValue("Body")) - w.WriteHeader(204) + // send device to app + devNum := mocktwilio.NewPhoneNumber() + _, err := srv.SendMessage(context.Background(), devNum, appPhone.Number, "hello") + require.NoError(t, err) + + // send app to device + v := make(url.Values) + v.Set("From", appPhone.Number) + v.Set("To", devNum) + v.Set("Body", "world") + v.Set("StatusCallback", appHTTP.URL+"/status") + resp, err := http.PostForm(twHTTP.URL+"/2010-04-01/Accounts/"+cfg.AccountSID+"/Messages.json", v) + require.NoError(t, err) + var msgStatus struct { + SID string + Status string + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&msgStatus)) + assert.Equal(t, "queued", msgStatus.Status) + + msg := <-srv.Messages() + assert.Equal(t, msg.ID(), msgStatus.SID) + + resp, err = http.Get(twHTTP.URL + "/2010-04-01/Accounts/" + cfg.AccountSID + "/Messages/" + msg.ID() + ".json") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&msgStatus)) + require.Equal(t, "sending", msgStatus.Status) + + require.NoError(t, msg.SetStatus(context.Background(), mocktwilio.MessageDelivered)) + + resp, err = http.Get(twHTTP.URL + "/2010-04-01/Accounts/" + cfg.AccountSID + "/Messages/" + msg.ID() + ".json") + require.NoError(t, err) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&msgStatus)) + require.Equal(t, "delivered", msgStatus.Status) + + require.NoError(t, srv.Close()) +} + +func TestServer_Voice(t *testing.T) { + cfg := mocktwilio.Config{ + AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + AuthToken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + OnError: func(ctx context.Context, err error) { + t.Errorf("mocktwilio: error: %v", err) + }, + } + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + t.Errorf("mocktwilio: unexpected request to %s", req.URL.String()) + w.WriteHeader(204) + }) + mux.HandleFunc("/voice", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + xml.NewEncoder(w).Encode(twiml.Response{ + Verbs: []twiml.Verb{ + &twiml.Say{Content: "hello there"}, + &twiml.Gather{ + Action: "voice-2", + Verbs: []twiml.GatherVerb{ + &twiml.Say{Content: "press 1"}, + }, + }, + }, }) - mux.HandleFunc("/status", func(w http.ResponseWriter, req *http.Request) { - t.Log(req.URL.Path) - w.WriteHeader(204) + }) + mux.HandleFunc("/voice-2", func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "1", req.FormValue("Digits")) + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + xml.NewEncoder(w).Encode(twiml.Response{ + Verbs: []twiml.Verb{ + &twiml.Gather{ + Action: "voice-3", + Verbs: []twiml.GatherVerb{ + &twiml.Say{Content: "that was expected"}, + }, + }, + &twiml.Say{Content: "end of call"}, + }, }) - appHTTP := httptest.NewServer(mux) - srv := mocktwilio.NewServer(cfg) - twHTTP := httptest.NewServer(srv) - defer appHTTP.Close() - defer srv.Close() - defer twHTTP.Close() - - appPhone := mocktwilio.Number{ - Number: mocktwilio.NewPhoneNumber(), - VoiceWebhookURL: appHTTP.URL + "/voice", - SMSWebhookURL: appHTTP.URL + "/sms", - } - require.NoError(t, srv.AddNumber(appPhone)) }) + + mux.HandleFunc("/voice-status", func(w http.ResponseWriter, req *http.Request) { + t.Log(req.URL.Path) + w.WriteHeader(204) + }) + appHTTP := httptest.NewServer(mux) + srv := mocktwilio.NewServer(cfg) + twHTTP := httptest.NewServer(srv) + defer appHTTP.Close() + defer srv.Close() + defer twHTTP.Close() + + appPhone := mocktwilio.Number{ + Number: mocktwilio.NewPhoneNumber(), + VoiceWebhookURL: appHTTP.URL + "/voice", + SMSWebhookURL: appHTTP.URL + "/sms", + } + require.NoError(t, srv.AddNumber(appPhone)) + + devNum := mocktwilio.NewPhoneNumber() + v := make(url.Values) + v.Set("From", appPhone.Number) + v.Set("To", devNum) + v.Set("Url", appHTTP.URL+"/voice") + v.Set("StatusCallback", appHTTP.URL+"/voice-status") + resp, err := http.PostForm(twHTTP.URL+"/2010-04-01/Accounts/"+cfg.AccountSID+"/Calls.json", v) + require.NoError(t, err) + var msgStatus struct { + SID string + Status string + } + data, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &msgStatus), string(data)) + assert.Equal(t, "queued", msgStatus.Status) + + call := <-srv.Calls() + ctx := context.Background() + require.NoError(t, call.Answer(ctx)) + assert.Equal(t, "hello there\npress 1\n", call.Text()) + require.NoError(t, call.Press(ctx, "1")) + assert.Equal(t, "that was expected\n", call.Text()) + require.NoError(t, call.PressTimeout(ctx)) + assert.Equal(t, "end of call\n", call.Text()) + + require.NoError(t, srv.Close()) } From 76dba6a65d7d19d14ecd3c2a58da9d96085c3857 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 26 Aug 2022 09:36:02 -0500 Subject: [PATCH 26/46] fix message service handling --- devtools/mocktwilio/handlenewmessage.go | 4 +- devtools/mocktwilio/msgstate.go | 4 +- devtools/mocktwilio/numberdb.go | 122 ++++++++++++++++++++++++ devtools/mocktwilio/server.go | 113 ++++++---------------- devtools/mocktwilio/server_test.go | 44 ++++++++- test/smoke/harness/harness.go | 4 +- 6 files changed, 199 insertions(+), 92 deletions(-) create mode 100644 devtools/mocktwilio/numberdb.go diff --git a/devtools/mocktwilio/handlenewmessage.go b/devtools/mocktwilio/handlenewmessage.go index ec6c6ab339..5b7ad71905 100644 --- a/devtools/mocktwilio/handlenewmessage.go +++ b/devtools/mocktwilio/handlenewmessage.go @@ -58,11 +58,11 @@ func (srv *Server) HandleNewMessage(w http.ResponseWriter, r *http.Request) { } if strings.HasPrefix(s.From, "MG") && s.MsgSID == "" { - s.MsgSID = s.ID + s.MsgSID = s.From } if s.MsgSID != "" { - if len(srv.numberSvc(s.MsgSID)) == 0 { + if len(srv.svcNumbers(s.MsgSID)) == 0 { respondErr(w, twError{ Status: 404, Message: fmt.Sprintf("The requested resource %s was not found", r.URL.String()), diff --git a/devtools/mocktwilio/msgstate.go b/devtools/mocktwilio/msgstate.go index 1c6a8c0149..0ec391d889 100644 --- a/devtools/mocktwilio/msgstate.go +++ b/devtools/mocktwilio/msgstate.go @@ -43,10 +43,10 @@ func (srv *Server) newMsgState() *msgState { func (s *msgState) lifecycle(ctx context.Context) { if s.MsgSID != "" { - nums := s.srv.numberSvc(s.MsgSID) + nums := s.srv.svcNumbers(s.MsgSID) idx := rand.Intn(len(nums)) newFrom := nums[idx] - err := s.setSendStatus(ctx, "queued", newFrom.Number) + err := s.setSendStatus(ctx, "queued", newFrom) if err != nil { s.srv.logErr(ctx, err) } diff --git a/devtools/mocktwilio/numberdb.go b/devtools/mocktwilio/numberdb.go new file mode 100644 index 0000000000..cf011a2f82 --- /dev/null +++ b/devtools/mocktwilio/numberdb.go @@ -0,0 +1,122 @@ +package mocktwilio + +import ( + "fmt" + "strings" + + "github.com/ttacon/libphonenumber" +) + +type numberDB struct { + svc map[string]*MsgService + num map[string]*Number + svcNum map[string]*MsgService +} + +func newNumberDB() *numberDB { + return &numberDB{ + svc: make(map[string]*MsgService), + num: make(map[string]*Number), + svcNum: make(map[string]*MsgService), + } +} + +func (db *numberDB) clearMsgSvc(id string) { + s := db.svc[id] + if s == nil { + return + } + + for _, n := range s.Numbers { + delete(db.svcNum, n) + } + delete(db.svc, id) +} + +func (db *numberDB) AddUpdateMsgService(ms MsgService) error { + if !strings.HasPrefix(ms.ID, "MG") { + return fmt.Errorf("invalid MsgService SID %s", ms.ID) + } + + if ms.SMSWebhookURL != "" { + err := validateURL(ms.SMSWebhookURL) + if err != nil { + return err + } + } + for _, nStr := range ms.Numbers { + _, err := libphonenumber.Parse(nStr, "") + if err != nil { + return fmt.Errorf("invalid phone number %s: %v", nStr, err) + } + } + + db.clearMsgSvc(ms.ID) + db.svc[ms.ID] = &ms + + for _, n := range ms.Numbers { + db.svcNum[n] = &ms + } + + return nil +} + +func (db *numberDB) AddUpdateNumber(n Number) error { + _, err := libphonenumber.Parse(n.Number, "") + if err != nil { + return fmt.Errorf("invalid phone number %s: %v", n.Number, err) + } + if n.SMSWebhookURL != "" { + err = validateURL(n.SMSWebhookURL) + if err != nil { + return err + } + } + if n.VoiceWebhookURL != "" { + err = validateURL(n.VoiceWebhookURL) + if err != nil { + return err + } + } + + db.num[n.Number] = &n + + return nil +} + +func (db *numberDB) MsgSvcExists(id string) bool { _, ok := db.svc[id]; return ok } +func (db *numberDB) NumberExists(s string) bool { + if _, ok := db.svcNum[s]; ok { + return true + } + + if _, ok := db.num[s]; ok { + return true + } + + return false +} + +func (db *numberDB) SMSWebhookURL(number string) string { + if s, ok := db.svcNum[number]; ok && s.SMSWebhookURL != "" { + return s.SMSWebhookURL + } + if n, ok := db.num[number]; ok && n.SMSWebhookURL != "" { + return n.SMSWebhookURL + } + return "" +} + +func (db *numberDB) VoiceWebhookURL(number string) string { + if n, ok := db.num[number]; ok && n.VoiceWebhookURL != "" { + return n.VoiceWebhookURL + } + return "" +} + +func (db *numberDB) MsgSvcNumbers(id string) []string { + if s, ok := db.svc[id]; ok { + return s.Numbers + } + return nil +} diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index 49f2fc8671..f988f36a9f 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -5,13 +5,11 @@ import ( "fmt" "net/http" "net/url" - "strings" "sync" "sync/atomic" "github.com/pkg/errors" "github.com/target/goalert/notification/twilio" - "github.com/ttacon/libphonenumber" ) // Config is used to configure the mock server. @@ -63,8 +61,7 @@ type Server struct { callStateDB chan map[string]*callState outboundCallCh chan *callState - numbersDB chan map[string]*Number - msgSvcDB chan map[string][]*Number + numbersDB chan *numberDB waitInFlight chan chan struct{} @@ -102,8 +99,7 @@ func NewServer(cfg Config) *Server { srv := &Server{ cfg: cfg, - msgSvcDB: make(chan map[string][]*Number, 1), - numbersDB: make(chan map[string]*Number, 1), + numbersDB: make(chan *numberDB, 1), mux: http.NewServeMux(), msgCh: make(chan Message, 10000), @@ -119,8 +115,7 @@ func NewServer(cfg Config) *Server { waitInFlight: make(chan chan struct{}), } - srv.msgSvcDB <- make(map[string][]*Number) - srv.numbersDB <- make(map[string]*Number) + srv.numbersDB <- newNumberDB() srv.msgStateDB <- make(map[string]*msgState) srv.callStateDB <- make(map[string]*callState) @@ -133,92 +128,42 @@ func NewServer(cfg Config) *Server { func (srv *Server) number(s string) *Number { db := <-srv.numbersDB - n := db[s] + if !db.NumberExists(s) { + srv.numbersDB <- db + return nil + } + + n := &Number{ + Number: s, + VoiceWebhookURL: db.VoiceWebhookURL(s), + SMSWebhookURL: db.SMSWebhookURL(s), + } srv.numbersDB <- db return n } -func (srv *Server) numberSvc(id string) []*Number { - db := <-srv.msgSvcDB - nums := db[id] - srv.msgSvcDB <- db - - return nums -} - -// AddNumber adds a new number to the mock server. -func (srv *Server) AddNumber(n Number) error { - _, err := libphonenumber.Parse(n.Number, "") - if err != nil { - return fmt.Errorf("invalid phone number %s: %v", n.Number, err) - } - if n.SMSWebhookURL != "" { - err = validateURL(n.SMSWebhookURL) - if err != nil { - return err - } - } - if n.VoiceWebhookURL != "" { - err = validateURL(n.VoiceWebhookURL) - if err != nil { - return err - } - } - +func (srv *Server) svcNumbers(id string) []string { db := <-srv.numbersDB - if _, ok := db[n.Number]; ok { - srv.numbersDB <- db - return fmt.Errorf("number %s already exists", n.Number) - } - db[n.Number] = &n + svc := db.MsgSvcNumbers(id) srv.numbersDB <- db - return nil + return svc } -// AddMsgService adds a new messaging service to the mock server. -func (srv *Server) AddMsgService(ms MsgService) error { - if !strings.HasPrefix(ms.ID, "MG") { - return fmt.Errorf("invalid MsgService SID %s", ms.ID) - } - - if ms.SMSWebhookURL != "" { - err := validateURL(ms.SMSWebhookURL) - if err != nil { - return err - } - } - for _, nStr := range ms.Numbers { - _, err := libphonenumber.Parse(nStr, "") - if err != nil { - return fmt.Errorf("invalid phone number %s: %v", nStr, err) - } - } - - msDB := <-srv.msgSvcDB - if _, ok := msDB[ms.ID]; ok { - srv.msgSvcDB <- msDB - return fmt.Errorf("MsgService SID %s already exists", ms.ID) - } - - numDB := <-srv.numbersDB - for _, nStr := range ms.Numbers { - n := numDB[nStr] - if n == nil { - n = &Number{Number: nStr} - numDB[nStr] = n - } - msDB[ms.ID] = append(msDB[ms.ID], n) - - if ms.SMSWebhookURL == "" { - continue - } +// AddUpdateNumber adds or updates a number. +func (srv *Server) AddUpdateNumber(n Number) error { + db := <-srv.numbersDB + err := db.AddUpdateNumber(n) + srv.numbersDB <- db - n.SMSWebhookURL = ms.SMSWebhookURL - } - srv.numbersDB <- numDB - srv.msgSvcDB <- msDB + return err +} - return nil +// AddUpdateMsgService or updates a messaging service. +func (srv *Server) AddUpdateMsgService(ms MsgService) error { + db := <-srv.numbersDB + err := db.AddUpdateMsgService(ms) + srv.numbersDB <- db + return err } func (srv *Server) nextID(prefix string) string { diff --git a/devtools/mocktwilio/server_test.go b/devtools/mocktwilio/server_test.go index 2389e2dfd8..b712431a08 100644 --- a/devtools/mocktwilio/server_test.go +++ b/devtools/mocktwilio/server_test.go @@ -16,6 +16,46 @@ import ( "github.com/target/goalert/devtools/mocktwilio/twiml" ) +func TestServer_SMS_MG(t *testing.T) { + cfg := mocktwilio.Config{ + AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + AuthToken: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + OnError: func(ctx context.Context, err error) { + t.Errorf("mocktwilio: error: %v", err) + }, + } + appPhone := mocktwilio.NewPhoneNumber() + appMsgID := mocktwilio.NewMsgServiceID() + devPhone := mocktwilio.NewPhoneNumber() + srv := mocktwilio.NewServer(cfg) + defer srv.Close() + err := srv.AddUpdateMsgService(mocktwilio.MsgService{ + ID: appMsgID, + Numbers: []string{appPhone}, + }) + require.NoError(t, err) + + twHTTP := httptest.NewServer(srv) + defer twHTTP.Close() + + v := make(url.Values) + v.Set("From", appMsgID) + v.Set("To", devPhone) + v.Set("Body", "world") + resp, err := http.PostForm(twHTTP.URL+"/2010-04-01/Accounts/"+cfg.AccountSID+"/Messages.json", v) + require.NoError(t, err) + var msgStatus struct { + SID string + Status string + } + data, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &msgStatus), string(data)) + + msg := <-srv.Messages() + assert.Equal(t, msg.ID(), msgStatus.SID) +} + func TestServer_SMS(t *testing.T) { cfg := mocktwilio.Config{ AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", @@ -50,7 +90,7 @@ func TestServer_SMS(t *testing.T) { VoiceWebhookURL: appHTTP.URL + "/voice", SMSWebhookURL: appHTTP.URL + "/sms", } - require.NoError(t, srv.AddNumber(appPhone)) + require.NoError(t, srv.AddUpdateNumber(appPhone)) // send device to app devNum := mocktwilio.NewPhoneNumber() @@ -150,7 +190,7 @@ func TestServer_Voice(t *testing.T) { VoiceWebhookURL: appHTTP.URL + "/voice", SMSWebhookURL: appHTTP.URL + "/sms", } - require.NoError(t, srv.AddNumber(appPhone)) + require.NoError(t, srv.AddUpdateNumber(appPhone)) devNum := mocktwilio.NewPhoneNumber() v := make(url.Values) diff --git a/test/smoke/harness/harness.go b/test/smoke/harness/harness.go index 78fac96e15..ee80b973f4 100644 --- a/test/smoke/harness/harness.go +++ b/test/smoke/harness/harness.go @@ -617,7 +617,7 @@ func (h *Harness) TwilioNumber(id string) string { } num := h.phoneCCG.Get("twilio" + id) - err := h.mockTw.AddNumber(mocktwilio.Number{ + err := h.mockTw.AddUpdateNumber(mocktwilio.Number{ Number: num, SMSWebhookURL: h.URL() + "/v1/twilio/sms/messages", VoiceWebhookURL: h.URL() + "/v1/twilio/voice/call", @@ -639,7 +639,7 @@ func (h *Harness) TwilioMessagingService() string { defer h.mx.Unlock() newID := mocktwilio.NewMsgServiceID() - err := h.mockTw.AddMsgService(mocktwilio.MsgService{ + err := h.mockTw.AddUpdateMsgService(mocktwilio.MsgService{ ID: newID, Numbers: []string{h.phoneCCG.Get("twilio:sid1"), h.phoneCCG.Get("twilio:sid2"), h.phoneCCG.Get("twilio:sid3")}, SMSWebhookURL: h.URL() + "/v1/twilio/sms/messages", From 52ab665247f3dbdf0cc2f2ad7bfcc1efc600bd4c Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 26 Aug 2022 09:40:40 -0500 Subject: [PATCH 27/46] include url and data in error --- notification/twilio/client.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/notification/twilio/client.go b/notification/twilio/client.go index 567ca578c6..79490cfb9f 100644 --- a/notification/twilio/client.go +++ b/notification/twilio/client.go @@ -134,7 +134,7 @@ func (c *Config) GetSMS(ctx context.Context, sid string) (*Message, error) { var e Exception err = json.Unmarshal(data, &e) if err != nil { - return nil, errors.Wrapf(err, "parse error response %s", string(data)) + return nil, errors.Wrapf(err, "parse error response '%s': %s", urlStr, string(data)) } return nil, &e } @@ -167,7 +167,7 @@ func (c *Config) GetVoice(ctx context.Context, sid string) (*Call, error) { var e Exception err = json.Unmarshal(data, &e) if err != nil { - return nil, errors.Wrap(err, "parse error response") + return nil, errors.Wrapf(err, "parse error response '%s': %s", urlStr, string(data)) } return nil, &e } @@ -246,7 +246,7 @@ func (c *Config) StartVoice(ctx context.Context, to string, o *VoiceOptions) (*C var e Exception err = json.Unmarshal(data, &e) if err != nil { - return nil, errors.Wrap(err, "parse error response") + return nil, errors.Wrapf(err, "parse error response '%s': %s", urlStr, string(data)) } return nil, &e } @@ -311,7 +311,7 @@ func (c *Config) SendSMS(ctx context.Context, to, body string, o *SMSOptions) (* var e Exception err = json.Unmarshal(data, &e) if err != nil { - return nil, errors.Wrapf(err, "parse error response %s", string(data)) + return nil, errors.Wrapf(err, "parse error response '%s': %s", urlStr, string(data)) } return nil, &e } From 76eb33520d24ce1cf506b0ffadde24c5ab070855 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 26 Aug 2022 09:45:30 -0500 Subject: [PATCH 28/46] use correct method for fetching voice status --- notification/twilio/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notification/twilio/client.go b/notification/twilio/client.go index 79490cfb9f..52bd871c4a 100644 --- a/notification/twilio/client.go +++ b/notification/twilio/client.go @@ -152,7 +152,7 @@ func (c *Config) GetSMS(ctx context.Context, sid string) (*Message, error) { func (c *Config) GetVoice(ctx context.Context, sid string) (*Call, error) { cfg := config.FromContext(ctx) urlStr := c.url("2010-04-01", "Accounts", cfg.Twilio.AccountSID, "Calls", sid+".json") - resp, err := c.post(ctx, urlStr, nil) + resp, err := c.get(ctx, urlStr) if err != nil { return nil, err } From 31b708b0206a951d470ca364b2f9c07e1370213c Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 26 Aug 2022 09:45:55 -0500 Subject: [PATCH 29/46] call status --- devtools/mocktwilio/handlecallstatus.go | 39 +++++++++++++++++++++++++ devtools/mocktwilio/http.go | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 devtools/mocktwilio/handlecallstatus.go diff --git a/devtools/mocktwilio/handlecallstatus.go b/devtools/mocktwilio/handlecallstatus.go new file mode 100644 index 0000000000..7811706aa1 --- /dev/null +++ b/devtools/mocktwilio/handlecallstatus.go @@ -0,0 +1,39 @@ +package mocktwilio + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// HandleCallStatus handles GET requests to /2010-04-01/Accounts//Calls/.json +func (srv *Server) HandleCallStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + respondErr(w, twError{ + Status: 405, + Code: 20004, + Message: "Method not allowed", + }) + return + } + + id := strings.TrimPrefix(r.URL.Path, srv.basePath()+"/Calls/") + id = strings.TrimSuffix(id, ".json") + + db := <-srv.callStateDB + s := db[id] + srv.callStateDB <- db + + if s == nil { + respondErr(w, twError{ + Status: 404, + Message: fmt.Sprintf("The requested resource %s was not found", r.URL.String()), + Code: 20404, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s) +} diff --git a/devtools/mocktwilio/http.go b/devtools/mocktwilio/http.go index b8e81ad9e3..2f18e7d7b0 100644 --- a/devtools/mocktwilio/http.go +++ b/devtools/mocktwilio/http.go @@ -18,7 +18,7 @@ func (srv *Server) initHTTP() { srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) srv.mux.HandleFunc(srv.basePath()+"/Calls.json", srv.HandleNewCall) - // s.mux.HandleFunc(base+"/Calls/", s.serveCallStatus) + srv.mux.HandleFunc(srv.basePath()+"/Calls/", srv.HandleCallStatus) // s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) } From 923ed6f42a68f568aebf0de9fcc2224cda74072a Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 26 Aug 2022 09:48:32 -0500 Subject: [PATCH 30/46] fix redirect parse --- devtools/mocktwilio/twiml/verbs.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/devtools/mocktwilio/twiml/verbs.go b/devtools/mocktwilio/twiml/verbs.go index 4de7f4e0e9..9fbdb9c79f 100644 --- a/devtools/mocktwilio/twiml/verbs.go +++ b/devtools/mocktwilio/twiml/verbs.go @@ -59,9 +59,14 @@ type Redirect struct { } func (r *Redirect) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { - if err := d.DecodeElement(&r, &start); err != nil { + type RedirectRaw Redirect + var rr struct { + RedirectRaw + } + if err := d.DecodeElement(&rr, &start); err != nil { return err } + *r = Redirect(rr.RedirectRaw) if r.Method == "" { r.Method = "POST" } From 22e52ccd1a4c2ed0521db9ba8da17daa18e27c12 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 26 Aug 2022 10:06:33 -0500 Subject: [PATCH 31/46] fix intepreter order --- devtools/mocktwilio/twiml/interpreter.go | 7 +- devtools/mocktwilio/twiml/interpreter_test.go | 67 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 devtools/mocktwilio/twiml/interpreter_test.go diff --git a/devtools/mocktwilio/twiml/interpreter.go b/devtools/mocktwilio/twiml/interpreter.go index 3ccd68e4cb..7e2f4031c4 100644 --- a/devtools/mocktwilio/twiml/interpreter.go +++ b/devtools/mocktwilio/twiml/interpreter.go @@ -45,16 +45,15 @@ func (i *Interpreter) Next() bool { break } - newVerbs := make([]any, 0, len(t.Verbs)+len(i.verbs)) + newVerbs := make([]Verb, 0, len(t.Verbs)+len(i.verbs)) i.state = t.Verbs[0] for _, v := range t.Verbs[1:] { newVerbs = append(newVerbs, v) } t.Verbs = nil newVerbs = append(newVerbs, t) - for _, v := range i.verbs[1:] { - newVerbs = append(newVerbs, v) - } + newVerbs = append(newVerbs, i.verbs[1:]...) + i.verbs = newVerbs case *Pause, *Say: i.state = t i.verbs = i.verbs[1:] diff --git a/devtools/mocktwilio/twiml/interpreter_test.go b/devtools/mocktwilio/twiml/interpreter_test.go new file mode 100644 index 0000000000..2b9955df87 --- /dev/null +++ b/devtools/mocktwilio/twiml/interpreter_test.go @@ -0,0 +1,67 @@ +package twiml + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInterpreter(t *testing.T) { + const doc = ` + + + + This is GoAlert with an alert notification. testing. + + + To acknowledge, press 4. + + + To close, press 6. + + + To disable voice notifications to this number, press 1. + + + To repeat this message, press star. + + +` + + i := NewInterpreter() + err := i.SetResponse([]byte(doc)) + require.NoError(t, err) + + assert.True(t, i.Next()) + assert.Equal(t, &Say{Content: "\n\t\t\tThis is GoAlert with an alert notification. testing.\n\t\t"}, i.Verb()) + + assert.True(t, i.Next()) + assert.Equal(t, &Say{Content: "\n\t\t\tTo acknowledge, press 4.\n\t\t"}, i.Verb()) + + assert.True(t, i.Next()) + assert.Equal(t, &Say{Content: "\n\t\t\tTo close, press 6.\n\t\t"}, i.Verb()) + + assert.True(t, i.Next()) + assert.Equal(t, &Say{Content: "\n\t\t\tTo disable voice notifications to this number, press 1.\n\t\t"}, i.Verb()) + + assert.True(t, i.Next()) + assert.Equal(t, &Say{Content: "\n\t\t\tTo repeat this message, press star.\n\t\t"}, i.Verb()) + + assert.True(t, i.Next()) + assert.Equal(t, &Gather{ + Action: "http://127.0.0.1:39111/api/v2/twilio/call?msgBody=VGhpcyBpcyBHb0FsZXJ0IHdpdGggYW4gYWxlcnQgbm90aWZpY2F0aW9uLiB0ZXN0aW5nLg&msgID=62b6e835-e086-4cc2-9030-b0b230fdd2b2&msgSubjectID=1&type=alert", + FinishOnKey: "#", + Input: "dtmf", + Language: "en-US", + Method: "POST", + NumDigitsCount: 1, + PartialResultCallbackMethod: "POST", + TimeoutDur: 10 * time.Second, + SpeechTimeoutDur: 10 * time.Second, + SpeechModel: "default", + }, i.Verb()) + + assert.False(t, i.Next()) +} From 228a90908b298803f96bb2dcc37b3837adece53d Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 29 Aug 2022 10:14:00 -0500 Subject: [PATCH 32/46] consistent call handling --- devtools/mocktwilio/assertcall.go | 99 ++++++++++++++++---------- devtools/mocktwilio/assertions.go | 18 +++-- devtools/mocktwilio/phoneassertions.go | 29 ++++---- 3 files changed, 85 insertions(+), 61 deletions(-) diff --git a/devtools/mocktwilio/assertcall.go b/devtools/mocktwilio/assertcall.go index 7ce29bd809..642f5a0109 100644 --- a/devtools/mocktwilio/assertcall.go +++ b/devtools/mocktwilio/assertcall.go @@ -10,6 +10,37 @@ type assertCall struct { Call } +func (dev *assertDev) ExpectVoice(keywords ...string) { + dev.t.Helper() + dev.ExpectCall().Answer().ExpectSay(keywords...).Hangup() +} + +func (call *assertCall) Answer() ExpectedCall { + call.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) + defer cancel() + + err := call.Call.Answer(ctx) + if err != nil { + call.t.Fatalf("mocktwilio: error answering call to %s: %v", call.To(), err) + } + + return call +} + +func (call *assertCall) Reject() { + call.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) + defer cancel() + + err := call.Call.Hangup(ctx, CallFailed) + if err != nil { + call.t.Fatalf("mocktwilio: error answering call to %s: %v", call.To(), err) + } +} + func (call *assertCall) Hangup() { call.t.Helper() @@ -22,12 +53,12 @@ func (call *assertCall) Hangup() { } } -func (call *assertCall) ThenPress(digits string) ExpectedCall { +func (call *assertCall) Press(digits string) ExpectedCall { call.t.Helper() ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) defer cancel() - err := call.Press(ctx, digits) + err := call.Call.Press(ctx, digits) if err != nil { call.t.Fatalf("mocktwilio: error pressing digits %s to %s: %v", digits, call.To(), err) } @@ -35,55 +66,43 @@ func (call *assertCall) ThenPress(digits string) ExpectedCall { return call } -func (call *assertCall) ThenExpect(keywords ...string) ExpectedCall { +func (call *assertCall) IdleForever() ExpectedCall { call.t.Helper() - if !containsAll(call.Text(), keywords) { - call.t.Fatalf("mocktwilio: expected call to %s to contain keywords: %v, but got: %s", call.To(), keywords, call.Text()) + ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) + defer cancel() + err := call.PressTimeout(ctx) + if err != nil { + call.t.Fatalf("mocktwilio: error waiting to %s: %v", call.To(), err) } return call } -func (dev *assertDev) ExpectVoice(keywords ...string) ExpectedCall { - dev.t.Helper() - - return &assertCall{ - assertDev: dev, - Call: dev.getVoice(false, keywords), - } -} - -func (dev *assertDev) RejectVoice(keywords ...string) { - dev.t.Helper() - - call := dev.getVoice(false, keywords) - ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) - defer cancel() +func (call *assertCall) ExpectSay(keywords ...string) ExpectedCall { + call.t.Helper() - err := call.Hangup(ctx, CallFailed) - if err != nil { - dev.t.Fatalf("mocktwilio: error ending call to %s: %v", call.To(), err) + if !containsAll(call.Text(), keywords) { + call.t.Fatalf("mocktwilio: expected call to %s to contain keywords: %v, but got: %s", call.To(), keywords, call.Text()) } -} -func (dev *assertDev) IgnoreUnexpectedVoice(keywords ...string) { - dev.ignoreCalls = append(dev.ignoreCalls, assertIgnore{number: dev.number, keywords: keywords}) + return call } -func (dev *assertDev) getVoice(prev bool, keywords []string) Call { +func (dev *assertDev) ExpectCall() RingingCall { dev.t.Helper() - if prev { - for idx, call := range dev.calls { - if !dev.matchMessage(dev.number, keywords, call) { - continue - } + for idx, call := range dev.calls { + if call.To() != dev.number { + continue + } - // Remove the call from the list of calls. - dev.calls = append(dev.calls[:idx], dev.calls[idx+1:]...) + // Remove the call from the list of calls. + dev.calls = append(dev.calls[:idx], dev.calls[idx+1:]...) - return call + return &assertCall{ + assertDev: dev, + Call: call, } } @@ -95,14 +114,18 @@ func (dev *assertDev) getVoice(prev bool, keywords []string) Call { for { select { case <-t.C: - dev.t.Fatalf("mocktwilio: timeout after %s waiting for a voice call to %s with keywords: %v", dev.Timeout, dev.number, keywords) + dev.t.Fatalf("mocktwilio: timeout after %s waiting for a voice call to %s", dev.Timeout, dev.number) case call := <-dev.Calls(): - if !dev.matchMessage(dev.number, keywords, call) { + dev.t.Logf("mocktwilio: incoming call from %s to %s", call.From(), call.To()) + if call.To() != dev.number { dev.calls = append(dev.calls, call) continue } - return call + return &assertCall{ + assertDev: dev, + Call: call, + } } } } diff --git a/devtools/mocktwilio/assertions.go b/devtools/mocktwilio/assertions.go index 4038cc19b2..08a1ee2567 100644 --- a/devtools/mocktwilio/assertions.go +++ b/devtools/mocktwilio/assertions.go @@ -51,8 +51,7 @@ type assertBase struct { messages []Message calls []Call - ignoreSMS []assertIgnore - ignoreCalls []assertIgnore + ignoreSMS []assertIgnore } type assertIgnore struct { @@ -68,6 +67,12 @@ type answerer interface { Answer(context.Context) error } +var ( + _ answerer = (Call)(nil) + _ texter = (Call)(nil) + _ texter = (Message)(nil) +) + func (a *assertions) matchMessage(destNumber string, keywords []string, t texter) bool { a.t.Helper() if t.To() != destNumber { @@ -140,16 +145,9 @@ checkMessages: a.t.Errorf("mocktwilio: unexpected SMS to %s: %s", msg.To(), msg.Text()) } -checkCalls: for _, call := range a.calls { - for _, ignore := range a.ignoreCalls { - if a.matchMessage(ignore.number, ignore.keywords, call) { - continue checkCalls - } - } - hasFailure = true - a.t.Errorf("mocktwilio: unexpected call to %s: %s", call.To(), call.Text()) + a.t.Errorf("mocktwilio: unexpected call to %s", call.To()) } if hasFailure { diff --git a/devtools/mocktwilio/phoneassertions.go b/devtools/mocktwilio/phoneassertions.go index 6b11d590bc..0febe1c359 100644 --- a/devtools/mocktwilio/phoneassertions.go +++ b/devtools/mocktwilio/phoneassertions.go @@ -28,30 +28,33 @@ type PhoneDevice interface { // RejectSMS will match against an SMS that matches ALL provided keywords (case-insensitive) and tell the server that delivery failed. RejectSMS(keywords ...string) - // ExpectVoice will match against a voice call where the spoken text matches ALL provided keywords (case-insensitive). - ExpectVoice(keywords ...string) ExpectedCall + // ExpectCall asserts for and returns the next phone call to the device. + ExpectCall() RingingCall - // RejectVoice will match against a voice call where the spoken text matches ALL provided keywords (case-insensitive) and tell the server that delivery failed. - RejectVoice(keywords ...string) + // ExpectVoice is a convenience method for PhoneDevice.ExpectCall().Answer().ExpectSay(keywords...).Hangup() + ExpectVoice(keywords ...string) // IgnoreUnexpectedSMS will cause any extra SMS messages (after processing ExpectSMS calls) that match // ALL keywords (case-insensitive) to not fail the test. IgnoreUnexpectedSMS(keywords ...string) +} - // IgnoreUnexpectedVoice will cause any extra voice calls (after processing ExpectVoice) that match - // ALL keywords (case-insensitive) to not fail the test. - IgnoreUnexpectedVoice(keywords ...string) +// RingingCall is a call that is ringing and not yet answered or rejected. +type RingingCall interface { + Answer() ExpectedCall + Reject() } // ExpectedCall represents a phone call. type ExpectedCall interface { - // ThenPress imitates a user entering a key on the phone. - ThenPress(digits string) ExpectedCall + // Press imitates a user entering a key on the phone. + Press(digits string) ExpectedCall - // ThenExpect asserts that the message matches ALL keywords (case-insensitive). - // - // Generally used as ThenPress().ThenExpect() - ThenExpect(keywords ...string) ExpectedCall + // IdleForever imitates a user waiting for a timeout (without pressing anything) on the phone. + IdleForever() ExpectedCall + + // ExpectSay asserts that the spoken message matches ALL keywords (case-insensitive). + ExpectSay(keywords ...string) ExpectedCall // Text will return the last full spoken message as text. Separate stanzas (e.g. multiple ``) are // separated by newline. From dfb2436b40a52ca9398edb6ee88fab30d350aef2 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 29 Aug 2022 10:14:46 -0500 Subject: [PATCH 33/46] api updates --- test/smoke/alertlog_test.go | 2 +- test/smoke/messagebundlevoice_test.go | 7 ++++--- test/smoke/twiliosid_test.go | 8 +++++--- test/smoke/twiliosmsstopstart_test.go | 4 ++-- test/smoke/twiliovoiceack_test.go | 8 +++++--- test/smoke/twiliovoiceclose_test.go | 8 +++++--- test/smoke/twiliovoicefailure_test.go | 3 ++- test/smoke/twiliovoicestop_test.go | 12 +++++++----- test/smoke/twiliovoiceverification_test.go | 3 ++- 9 files changed, 33 insertions(+), 22 deletions(-) diff --git a/test/smoke/alertlog_test.go b/test/smoke/alertlog_test.go index ab0eaddc45..7856c95778 100644 --- a/test/smoke/alertlog_test.go +++ b/test/smoke/alertlog_test.go @@ -182,7 +182,7 @@ func TestAlertLog(t *testing.T) { EPStep: true, EPStepUser: true, }, func(t *testing.T, h *harness.Harness) { - h.Twilio(t).Device(h.Phone("1")).RejectVoice("foo") + h.Twilio(t).Device(h.Phone("1")).ExpectCall().Reject() }, func(t *testing.T, h *harness.Harness, l alertLogs) { msg := l.Alert.RecentEvents.Nodes[0].Message details := l.Alert.RecentEvents.Nodes[0].State.Details diff --git a/test/smoke/messagebundlevoice_test.go b/test/smoke/messagebundlevoice_test.go index 73d9a69cc5..4270798dec 100644 --- a/test/smoke/messagebundlevoice_test.go +++ b/test/smoke/messagebundlevoice_test.go @@ -50,9 +50,10 @@ func TestMessageBundle_Voice(t *testing.T) { tw := h.Twilio(t) d1 := tw.Device(h.Phone("1")) - d1.ExpectVoice("My Service", "4 unacknowledged"). - ThenPress("4"). - ThenExpect("Acknowledged all") + d1.ExpectCall().Answer().ExpectSay("My Service", "4 unacknowledged"). + Press("4"). + ExpectSay("Acknowledged all"). + Hangup() h.GraphQLQuery2(`mutation{ updateAlerts(input: {alertIDs: [1,2,3,4], newStatus: StatusClosed}){id} }`) } diff --git a/test/smoke/twiliosid_test.go b/test/smoke/twiliosid_test.go index 7f5db330e6..d3de836f2d 100644 --- a/test/smoke/twiliosid_test.go +++ b/test/smoke/twiliosid_test.go @@ -59,9 +59,11 @@ func TestTwilioSID(t *testing.T) { sms.ThenReply("ack 1"). ThenExpect("acknowledged") - d1.ExpectVoice("testing"). - ThenPress("6"). - ThenExpect("closed") + d1.ExpectCall().Answer(). + ExpectSay("testing"). + Press("6"). + ExpectSay("closed"). + Hangup() h.FastForward(time.Hour) // no more messages diff --git a/test/smoke/twiliosmsstopstart_test.go b/test/smoke/twiliosmsstopstart_test.go index c1067c58cc..d39c16d9d0 100644 --- a/test/smoke/twiliosmsstopstart_test.go +++ b/test/smoke/twiliosmsstopstart_test.go @@ -49,11 +49,11 @@ func TestTwilioSMSStopStart(t *testing.T) { d1 := h.Twilio(t).Device(h.Phone("1")) // disable SMS d1.ExpectSMS("testing").ThenReply("stop") - d1.ExpectVoice("testing").Hangup() + d1.ExpectVoice("testing") // trigger update - only VOICE should still be enabled h.Escalate(1234, 0) - d1.ExpectVoice("testing").Hangup() + d1.ExpectVoice("testing") // re-enable SMS d1.SendSMS("start") diff --git a/test/smoke/twiliovoiceack_test.go b/test/smoke/twiliovoiceack_test.go index b8e5c05d27..9af0a1b0e3 100644 --- a/test/smoke/twiliovoiceack_test.go +++ b/test/smoke/twiliovoiceack_test.go @@ -49,9 +49,11 @@ func TestTwilioVoiceAck(t *testing.T) { tw := h.Twilio(t) d1 := tw.Device(h.Phone("1")) - d1.ExpectVoice("testing"). - ThenPress("4"). - ThenExpect("acknowledged") + d1.ExpectCall().Answer(). + ExpectSay("testing"). + Press("4"). + ExpectSay("acknowledged"). + Hangup() h.FastForward(time.Hour) // no more messages diff --git a/test/smoke/twiliovoiceclose_test.go b/test/smoke/twiliovoiceclose_test.go index 8968cdc2ea..cc2436d025 100644 --- a/test/smoke/twiliovoiceclose_test.go +++ b/test/smoke/twiliovoiceclose_test.go @@ -49,9 +49,11 @@ func TestTwilioVoiceClose(t *testing.T) { tw := h.Twilio(t) d1 := tw.Device(h.Phone("1")) - d1.ExpectVoice("testing"). - ThenPress("6"). - ThenExpect("closed") + d1.ExpectCall().Answer(). + ExpectSay("testing"). + Press("6"). + ExpectSay("closed"). + Hangup() h.FastForward(time.Hour) // no more messages diff --git a/test/smoke/twiliovoicefailure_test.go b/test/smoke/twiliovoicefailure_test.go index ca829a85e4..a47cb44714 100644 --- a/test/smoke/twiliovoicefailure_test.go +++ b/test/smoke/twiliovoicefailure_test.go @@ -45,5 +45,6 @@ func TestTwilioVoiceFailure(t *testing.T) { defer h.Close() d1 := h.Twilio(t).Device(h.Phone("1")) - d1.RejectVoice("testing") + d1.ExpectCall().Reject() + d1.ExpectCall().Answer().ExpectSay("testing").Hangup() } diff --git a/test/smoke/twiliovoicestop_test.go b/test/smoke/twiliovoicestop_test.go index ab4d1b80ac..73e05bfa4b 100644 --- a/test/smoke/twiliovoicestop_test.go +++ b/test/smoke/twiliovoicestop_test.go @@ -50,11 +50,13 @@ func TestTwilioVoiceStop(t *testing.T) { d1 := h.Twilio(t).Device(h.Phone("1")) - d1.ExpectVoice("testing"). - ThenPress("1"). - ThenExpect("unenrollment"). - ThenPress("3"). - ThenExpect("goodbye") + d1.ExpectCall().Answer(). + ExpectSay("testing"). + Press("1"). + ExpectSay("unenrollment"). + Press("3"). + ExpectSay("goodbye"). + Hangup() h.FastForward(30 * time.Minute) diff --git a/test/smoke/twiliovoiceverification_test.go b/test/smoke/twiliovoiceverification_test.go index da48eb2a13..14c91155aa 100644 --- a/test/smoke/twiliovoiceverification_test.go +++ b/test/smoke/twiliovoiceverification_test.go @@ -65,7 +65,8 @@ func TestTwilioVoiceVerification(t *testing.T) { tw := h.Twilio(t) d1 := tw.Device(h.Phone("1")) - call := d1.ExpectVoice("verification") + call := d1.ExpectCall().Answer().ExpectSay("verification") + defer call.Hangup() codeStr := strings.Map(func(r rune) rune { if r >= '0' && r <= '9' { From 766a4314ed46047ca036730400e13a0aed777008 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 29 Aug 2022 10:22:05 -0500 Subject: [PATCH 34/46] additional refresh --- devtools/mocktwilio/assertsms.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go index a03e2079a1..593621657d 100644 --- a/devtools/mocktwilio/assertsms.go +++ b/devtools/mocktwilio/assertsms.go @@ -85,6 +85,9 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords t := time.NewTimer(dev.Timeout) defer t.Stop() + ref := time.NewTicker(time.Second / 2) + defer ref.Stop() + for { select { case <-t.C: @@ -95,6 +98,8 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords } dev.t.FailNow() + case <-ref.C: + dev.refresh() case msg := <-dev.Messages(): if !dev.matchMessage(dev.number, keywords, msg) { dev.messages = append(dev.messages, msg) From 68a1d3b7cfc5a647696b155e8535735f227c4e8d Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 29 Aug 2022 10:23:59 -0500 Subject: [PATCH 35/46] refresh while waiting for calls --- devtools/mocktwilio/assertcall.go | 5 +++++ devtools/mocktwilio/assertsms.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/devtools/mocktwilio/assertcall.go b/devtools/mocktwilio/assertcall.go index 642f5a0109..c5f21a0e43 100644 --- a/devtools/mocktwilio/assertcall.go +++ b/devtools/mocktwilio/assertcall.go @@ -111,10 +111,15 @@ func (dev *assertDev) ExpectCall() RingingCall { t := time.NewTimer(dev.Timeout) defer t.Stop() + ref := time.NewTicker(time.Second) + defer ref.Stop() + for { select { case <-t.C: dev.t.Fatalf("mocktwilio: timeout after %s waiting for a voice call to %s", dev.Timeout, dev.number) + case <-ref.C: + dev.refresh() case call := <-dev.Calls(): dev.t.Logf("mocktwilio: incoming call from %s to %s", call.From(), call.To()) if call.To() != dev.number { diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go index 593621657d..9813ffaf63 100644 --- a/devtools/mocktwilio/assertsms.go +++ b/devtools/mocktwilio/assertsms.go @@ -85,7 +85,7 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords t := time.NewTimer(dev.Timeout) defer t.Stop() - ref := time.NewTicker(time.Second / 2) + ref := time.NewTicker(time.Second) defer ref.Stop() for { From bf9cbe8faf306ab350276c9c84296c09c48dbe46 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 29 Aug 2022 10:28:33 -0500 Subject: [PATCH 36/46] fix voice failure test --- devtools/mocktwilio/assertcall.go | 6 ++++-- devtools/mocktwilio/phoneassertions.go | 1 + test/smoke/twiliovoicefailure_test.go | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/devtools/mocktwilio/assertcall.go b/devtools/mocktwilio/assertcall.go index c5f21a0e43..2bf9579cff 100644 --- a/devtools/mocktwilio/assertcall.go +++ b/devtools/mocktwilio/assertcall.go @@ -29,13 +29,15 @@ func (call *assertCall) Answer() ExpectedCall { return call } -func (call *assertCall) Reject() { +func (call *assertCall) Reject() { call.RejectWith(CallFailed) } + +func (call *assertCall) RejectWith(status FinalCallStatus) { call.t.Helper() ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) defer cancel() - err := call.Call.Hangup(ctx, CallFailed) + err := call.Call.Hangup(ctx, status) if err != nil { call.t.Fatalf("mocktwilio: error answering call to %s: %v", call.To(), err) } diff --git a/devtools/mocktwilio/phoneassertions.go b/devtools/mocktwilio/phoneassertions.go index 0febe1c359..b49956f75e 100644 --- a/devtools/mocktwilio/phoneassertions.go +++ b/devtools/mocktwilio/phoneassertions.go @@ -43,6 +43,7 @@ type PhoneDevice interface { type RingingCall interface { Answer() ExpectedCall Reject() + RejectWith(FinalCallStatus) } // ExpectedCall represents a phone call. diff --git a/test/smoke/twiliovoicefailure_test.go b/test/smoke/twiliovoicefailure_test.go index a47cb44714..a2a5776eae 100644 --- a/test/smoke/twiliovoicefailure_test.go +++ b/test/smoke/twiliovoicefailure_test.go @@ -3,6 +3,7 @@ package smoke import ( "testing" + "github.com/target/goalert/devtools/mocktwilio" "github.com/target/goalert/test/smoke/harness" ) @@ -45,6 +46,6 @@ func TestTwilioVoiceFailure(t *testing.T) { defer h.Close() d1 := h.Twilio(t).Device(h.Phone("1")) - d1.ExpectCall().Reject() + d1.ExpectCall().RejectWith(mocktwilio.CallBusy) d1.ExpectCall().Answer().ExpectSay("testing").Hangup() } From 45a0f03048af44b93a2ede2992ccb6a103238089 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 29 Aug 2022 11:00:11 -0500 Subject: [PATCH 37/46] fix carrier info lookup --- .../mocktwilio/{lookup.go => handlelookup.go} | 36 +++++++++++-------- devtools/mocktwilio/http.go | 2 +- devtools/mocktwilio/server.go | 15 +++++--- test/smoke/harness/harness.go | 3 +- 4 files changed, 34 insertions(+), 22 deletions(-) rename devtools/mocktwilio/{lookup.go => handlelookup.go} (53%) diff --git a/devtools/mocktwilio/lookup.go b/devtools/mocktwilio/handlelookup.go similarity index 53% rename from devtools/mocktwilio/lookup.go rename to devtools/mocktwilio/handlelookup.go index a21c0099a8..360105d1ae 100644 --- a/devtools/mocktwilio/lookup.go +++ b/devtools/mocktwilio/handlelookup.go @@ -6,22 +6,31 @@ import ( "path" "strconv" - "github.com/target/goalert/notification/twilio" "github.com/ttacon/libphonenumber" ) -func (s *Server) serveLookup(w http.ResponseWriter, req *http.Request) { +type CarrierInfo struct { + Name string `json:"name"` + Type string `json:"type"` + + // MobileCC is the mobile country code. + MobileCC string `json:"mobile_country_code"` + + // MobileNC is the mobile network code. + MobileNC string `json:"mobile_network_code"` +} + +func (s *Server) HandleLookup(w http.ResponseWriter, req *http.Request) { number := path.Base(req.URL.Path) inclCarrier := req.URL.Query().Get("Type") == "carrier" var info struct { - CallerName *struct{} `json:"caller_name"` - Carrier *twilio.CarrierInfo `json:"carrier"` - CC string `json:"country_code"` - Fmt string `json:"national_format"` - Number string `json:"phone_number"` - AddOns *struct{} `json:"add_ons"` - URL string `json:"url"` + CallerName *struct{} `json:"caller_name"` + Carrier *CarrierInfo `json:"carrier"` + CC string `json:"country_code"` + Fmt string `json:"national_format"` + Number string `json:"phone_number"` + URL string `json:"url"` } n, err := libphonenumber.Parse(number, "") if err != nil { @@ -35,12 +44,9 @@ func (s *Server) serveLookup(w http.ResponseWriter, req *http.Request) { info.URL = req.URL.String() if inclCarrier { - s.carrierInfoMx.Lock() - c, ok := s.carrierInfo[info.Number] - s.carrierInfoMx.Unlock() - if ok { - info.Carrier = &c - } + db := <-s.numInfoCh + info.Carrier = db[info.Number] + s.numInfoCh <- db } data, err := json.Marshal(info) diff --git a/devtools/mocktwilio/http.go b/devtools/mocktwilio/http.go index 2f18e7d7b0..30cdace61f 100644 --- a/devtools/mocktwilio/http.go +++ b/devtools/mocktwilio/http.go @@ -19,7 +19,7 @@ func (srv *Server) initHTTP() { srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) srv.mux.HandleFunc(srv.basePath()+"/Calls.json", srv.HandleNewCall) srv.mux.HandleFunc(srv.basePath()+"/Calls/", srv.HandleCallStatus) - // s.mux.HandleFunc("/v1/PhoneNumbers/", s.serveLookup) + srv.mux.HandleFunc("/v1/PhoneNumbers/", srv.HandleLookup) } func (s *Server) post(ctx context.Context, url string, v url.Values) ([]byte, error) { diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index f988f36a9f..f0166f1990 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -9,7 +9,6 @@ import ( "sync/atomic" "github.com/pkg/errors" - "github.com/target/goalert/notification/twilio" ) // Config is used to configure the mock server. @@ -61,6 +60,8 @@ type Server struct { callStateDB chan map[string]*callState outboundCallCh chan *callState + numInfoCh chan map[string]*CarrierInfo + numbersDB chan *numberDB waitInFlight chan chan struct{} @@ -74,9 +75,6 @@ type Server struct { id uint64 workers sync.WaitGroup - - carrierInfo map[string]twilio.CarrierInfo - carrierInfoMx sync.Mutex } func validateURL(s string) error { @@ -114,10 +112,13 @@ func NewServer(cfg Config) *Server { shutdownDone: make(chan struct{}), waitInFlight: make(chan chan struct{}), + + numInfoCh: make(chan map[string]*CarrierInfo, 1), } srv.numbersDB <- newNumberDB() srv.msgStateDB <- make(map[string]*msgState) srv.callStateDB <- make(map[string]*callState) + srv.numInfoCh <- make(map[string]*CarrierInfo) srv.initHTTP() @@ -126,6 +127,12 @@ func NewServer(cfg Config) *Server { return srv } +func (srv *Server) SetCarrierInfo(number string, info CarrierInfo) { + db := <-srv.numInfoCh + db[number] = &info + srv.numInfoCh <- db +} + func (srv *Server) number(s string) *Number { db := <-srv.numbersDB if !db.NumberExists(s) { diff --git a/test/smoke/harness/harness.go b/test/smoke/harness/harness.go index ee80b973f4..013d554169 100644 --- a/test/smoke/harness/harness.go +++ b/test/smoke/harness/harness.go @@ -605,8 +605,7 @@ func (h *Harness) Close() error { // SetCarrierName will set the carrier name for the given phone number. func (h *Harness) SetCarrierName(number, name string) { - h.t.Fatal("not implemented") - // h.tw.Server.SetCarrierInfo(number, twilio.CarrierInfo{Name: name}) + h.mockTw.SetCarrierInfo(number, mocktwilio.CarrierInfo{Name: name}) } // TwilioNumber will return a registered (or register if missing) Twilio number for the given ID. From 49f8662242e353bb93039aa74ec2ef191c60832a Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Mon, 29 Aug 2022 11:02:29 -0500 Subject: [PATCH 38/46] remove unused code --- devtools/mocktwilio/errors.go | 12 ----------- devtools/mocktwilio/server.go | 2 -- test/smoke/harness/matchers.go | 38 ---------------------------------- 3 files changed, 52 deletions(-) delete mode 100644 test/smoke/harness/matchers.go diff --git a/devtools/mocktwilio/errors.go b/devtools/mocktwilio/errors.go index a8d261ee20..b3b6d8f121 100644 --- a/devtools/mocktwilio/errors.go +++ b/devtools/mocktwilio/errors.go @@ -34,15 +34,3 @@ func IsStatusUpdateErr(err error) bool { e, ok := err.(statErr) return ok && e.IsStatusUpdate() } - -type statusErr struct { - err error -} - -func (s statusErr) IsStatusUpdate() bool { return true } - -func (s statusErr) Error() string { - return s.Error() -} - -func (s statusErr) Unwrap() error { return s.err } diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index f0166f1990..b7baf6fe91 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -73,8 +73,6 @@ type Server struct { shutdownDone chan struct{} id uint64 - - workers sync.WaitGroup } func validateURL(s string) error { diff --git a/test/smoke/harness/matchers.go b/test/smoke/harness/matchers.go deleted file mode 100644 index 6d471bac67..0000000000 --- a/test/smoke/harness/matchers.go +++ /dev/null @@ -1,38 +0,0 @@ -package harness - -import "strings" - -func containsAllIgnoreCase(s string, substrs []string) bool { - s = strings.ToLower(s) - for _, sub := range substrs { - if !strings.Contains(s, strings.ToLower(sub)) { - return false - } - } - - return true -} - -type messageMatcher struct { - number string - keywords []string -} -type devMessage interface { - To() string - Body() string -} - -func (m messageMatcher) match(msg devMessage) bool { - return msg.To() == m.number && containsAllIgnoreCase(msg.Body(), m.keywords) -} - -type anyMessage []messageMatcher - -func (any anyMessage) match(msg devMessage) bool { - for _, m := range any { - if m.match(msg) { - return true - } - } - return false -} From bc91ba200b3fc969e0c328c2b203a9a8c2d24b91 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 31 Aug 2022 08:31:58 -0500 Subject: [PATCH 39/46] cleanup and comment mocktwilio lib --- devtools/mocktwilio/assertcall.go | 56 +++++++++++++++++------------ devtools/mocktwilio/assertions.go | 51 ++++++++++---------------- devtools/mocktwilio/assertsms.go | 49 ++++++++++++++----------- devtools/mocktwilio/callstate.go | 11 ++---- devtools/mocktwilio/handlelookup.go | 5 +-- 5 files changed, 87 insertions(+), 85 deletions(-) diff --git a/devtools/mocktwilio/assertcall.go b/devtools/mocktwilio/assertcall.go index 2bf9579cff..2b95b9c827 100644 --- a/devtools/mocktwilio/assertcall.go +++ b/devtools/mocktwilio/assertcall.go @@ -2,6 +2,7 @@ package mocktwilio import ( "context" + "fmt" "time" ) @@ -10,11 +11,31 @@ type assertCall struct { Call } +func (a *assertions) newAssertCall(baseCall Call) *assertCall { + dev := &assertDev{a, baseCall.From()} + return dev.newAssertCall(baseCall) +} + +func (dev *assertDev) newAssertCall(baseCall Call) *assertCall { + call := &assertCall{ + assertDev: dev, + Call: baseCall, + } + dev.t.Logf("mocktwilio: incoming %s", call) + return call +} + func (dev *assertDev) ExpectVoice(keywords ...string) { dev.t.Helper() dev.ExpectCall().Answer().ExpectSay(keywords...).Hangup() } +// String returns a string representation of the call for test output. +func (call *assertCall) String() string { + return fmt.Sprintf("call from %s to %s", call.From(), call.To()) +} + +// Answer is part of the RingingCall interface. func (call *assertCall) Answer() ExpectedCall { call.t.Helper() @@ -23,7 +44,7 @@ func (call *assertCall) Answer() ExpectedCall { err := call.Call.Answer(ctx) if err != nil { - call.t.Fatalf("mocktwilio: error answering call to %s: %v", call.To(), err) + call.t.Fatalf("mocktwilio: answer %s: %v", call, err) } return call @@ -39,20 +60,13 @@ func (call *assertCall) RejectWith(status FinalCallStatus) { err := call.Call.Hangup(ctx, status) if err != nil { - call.t.Fatalf("mocktwilio: error answering call to %s: %v", call.To(), err) + call.t.Fatalf("mocktwilio: hangup %s with '%s': %v", call, status, err) } } func (call *assertCall) Hangup() { call.t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) - defer cancel() - - err := call.Call.Hangup(ctx, CallCompleted) - if err != nil { - call.t.Fatalf("mocktwilio: error ending call to %s: %v", call.To(), err) - } + call.RejectWith(CallCompleted) } func (call *assertCall) Press(digits string) ExpectedCall { @@ -60,9 +74,10 @@ func (call *assertCall) Press(digits string) ExpectedCall { ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) defer cancel() + err := call.Call.Press(ctx, digits) if err != nil { - call.t.Fatalf("mocktwilio: error pressing digits %s to %s: %v", digits, call.To(), err) + call.t.Fatalf("mocktwilio: press '%s' on %s: %v", digits, call, err) } return call @@ -73,9 +88,10 @@ func (call *assertCall) IdleForever() ExpectedCall { ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) defer cancel() + err := call.PressTimeout(ctx) if err != nil { - call.t.Fatalf("mocktwilio: error waiting to %s: %v", call.To(), err) + call.t.Fatalf("mocktwilio: wait on %s: %v", call, err) } return call @@ -85,7 +101,7 @@ func (call *assertCall) ExpectSay(keywords ...string) ExpectedCall { call.t.Helper() if !containsAll(call.Text(), keywords) { - call.t.Fatalf("mocktwilio: expected call to %s to contain keywords: %v, but got: %s", call.To(), keywords, call.Text()) + call.t.Fatalf("mocktwilio: expected %s to say: %v, but got: %s", call, keywords, call.Text()) } return call @@ -102,10 +118,7 @@ func (dev *assertDev) ExpectCall() RingingCall { // Remove the call from the list of calls. dev.calls = append(dev.calls[:idx], dev.calls[idx+1:]...) - return &assertCall{ - assertDev: dev, - Call: call, - } + return call } dev.refresh() @@ -122,17 +135,14 @@ func (dev *assertDev) ExpectCall() RingingCall { dev.t.Fatalf("mocktwilio: timeout after %s waiting for a voice call to %s", dev.Timeout, dev.number) case <-ref.C: dev.refresh() - case call := <-dev.Calls(): - dev.t.Logf("mocktwilio: incoming call from %s to %s", call.From(), call.To()) + case baseCall := <-dev.Calls(): + call := dev.newAssertCall(baseCall) if call.To() != dev.number { dev.calls = append(dev.calls, call) continue } - return &assertCall{ - assertDev: dev, - Call: call, - } + return call } } } diff --git a/devtools/mocktwilio/assertions.go b/devtools/mocktwilio/assertions.go index 08a1ee2567..b007f7eae4 100644 --- a/devtools/mocktwilio/assertions.go +++ b/devtools/mocktwilio/assertions.go @@ -8,7 +8,10 @@ import ( type AssertConfig struct { ServerAPI - Timeout time.Duration + // Timeout is used to set the timeout for all operations, expected messages/calls as well as API calls for things like answering a call. + Timeout time.Duration + + // AppPhoneNumber is the phone number that the application will use to make calls and send messages. AppPhoneNumber string // RefreshFunc will be called before waiting for new messages or calls to arrive. @@ -19,9 +22,13 @@ type AssertConfig struct { RefreshFunc func() } +// ServerAPI is the interface for the mocktwilio server. type ServerAPI interface { SendMessage(ctx context.Context, from, to, body string) (Message, error) + + // WaitInFlight should return after all in-flight messages are processed. WaitInFlight(context.Context) error + Messages() <-chan Message Calls() <-chan Call } @@ -48,8 +55,8 @@ type assertions struct { type assertBase struct { AssertConfig - messages []Message - calls []Call + messages []*assertSMS + calls []*assertCall ignoreSMS []assertIgnore } @@ -59,36 +66,12 @@ type assertIgnore struct { keywords []string } -type texter interface { - To() string - Text() string -} -type answerer interface { - Answer(context.Context) error -} - -var ( - _ answerer = (Call)(nil) - _ texter = (Call)(nil) - _ texter = (Message)(nil) -) - -func (a *assertions) matchMessage(destNumber string, keywords []string, t texter) bool { +func (a *assertions) matchMessage(destNumber string, keywords []string, t *assertSMS) bool { a.t.Helper() if t.To() != destNumber { return false } - if ans, ok := t.(answerer); ok { - ctx, cancel := context.WithTimeout(context.Background(), a.Timeout) - defer cancel() - - err := ans.Answer(ctx) - if err != nil { - a.t.Fatalf("mocktwilio: error answering call to %s: %v", t.To(), err) - } - } - return containsAll(t.Text(), keywords) } @@ -100,10 +83,12 @@ func (a *assertions) refresh() { a.RefreshFunc() } +// Device will allow expecting calls and messages from a particular destination number. func (a *assertions) Device(number string) PhoneDevice { return &assertDev{a, number} } +// WaitAndAssert will ensure no unexpected messages or calls are received. func (a *assertions) WaitAndAssert() { a.t.Helper() @@ -115,7 +100,8 @@ drainMessages: for { select { case msg := <-a.Messages(): - a.messages = append(a.messages, msg) + sms := a.newAssertSMS(msg) + a.messages = append(a.messages, sms) default: break drainMessages } @@ -124,7 +110,8 @@ drainMessages: drainCalls: for { select { - case call := <-a.Calls(): + case baseCall := <-a.Calls(): + call := a.newAssertCall(baseCall) a.calls = append(a.calls, call) default: break drainCalls @@ -142,12 +129,12 @@ checkMessages: } hasFailure = true - a.t.Errorf("mocktwilio: unexpected SMS to %s: %s", msg.To(), msg.Text()) + a.t.Errorf("mocktwilio: unexpected %s", msg) } for _, call := range a.calls { hasFailure = true - a.t.Errorf("mocktwilio: unexpected call to %s", call.To()) + a.t.Errorf("mocktwilio: unexpected %s", call) } if hasFailure { diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go index 9813ffaf63..8e5bbd32b6 100644 --- a/devtools/mocktwilio/assertsms.go +++ b/devtools/mocktwilio/assertsms.go @@ -2,6 +2,8 @@ package mocktwilio import ( "context" + "fmt" + "strconv" "time" ) @@ -10,6 +12,20 @@ type assertSMS struct { Message } +func (a *assertions) newAssertSMS(baseSMS Message) *assertSMS { + dev := &assertDev{a, baseSMS.From()} + sms := &assertSMS{ + assertDev: dev, + Message: baseSMS, + } + dev.t.Logf("mocktwilio: incoming %s", sms) + return sms +} + +func (sms *assertSMS) String() string { + return fmt.Sprintf("SMS %s from %s to %s", strconv.Quote(sms.Text()), sms.From(), sms.To()) +} + func (sms *assertSMS) ThenExpect(keywords ...string) ExpectedSMS { sms.t.Helper() return sms._ExpectSMS(false, MessageDelivered, keywords...) @@ -28,7 +44,7 @@ func (dev *assertDev) SendSMS(body string) { _, err := dev.SendMessage(ctx, dev.number, dev.AppPhoneNumber, body) if err != nil { - dev.t.Fatalf("mocktwilio: send SMS %s to %s: %v", body, dev.number, err) + dev.t.Fatalf("mocktwilio: send SMS %s to %s: %v", strconv.Quote(body), dev.number, err) } } @@ -58,25 +74,23 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords keywords = toLowerSlice(keywords) if prev { - for idx, msg := range dev.messages { - if !dev.matchMessage(dev.number, keywords, msg) { + for idx, sms := range dev.messages { + if !dev.matchMessage(dev.number, keywords, sms) { continue } ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) defer cancel() - err := msg.SetStatus(ctx, status) + err := sms.SetStatus(ctx, status) if err != nil { - dev.t.Fatalf("mocktwilio: error setting SMS status %s to %s: %v", status, msg.To(), err) + dev.t.Fatalf("mocktwilio: set status '%s' on %s: %v", status, sms, err) } - dev.t.Log("mocktwilio: received expected SMS from", msg.From(), "to", msg.To(), "with text", msg.Text()) - // remove the message from the list of messages dev.messages = append(dev.messages[:idx], dev.messages[idx+1:]...) - return &assertSMS{assertDev: dev, Message: msg} + return sms } } @@ -91,31 +105,26 @@ func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords for { select { case <-t.C: - dev.t.Errorf("mocktwilio: timeout after %s waiting for an SMS to %s with keywords: %v", dev.Timeout, dev.number, keywords) - for i, msg := range dev.messages { - dev.t.Errorf("mocktwilio: message %d: from=%s; to=%s; text=%s", i, msg.From(), msg.To(), msg.Text()) - } - dev.t.FailNow() case <-ref.C: dev.refresh() - case msg := <-dev.Messages(): - if !dev.matchMessage(dev.number, keywords, msg) { - dev.messages = append(dev.messages, msg) + case baseSMS := <-dev.Messages(): + sms := dev.newAssertSMS(baseSMS) + if !dev.matchMessage(dev.number, keywords, sms) { + dev.messages = append(dev.messages, sms) continue } ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) defer cancel() - err := msg.SetStatus(ctx, status) + err := sms.SetStatus(ctx, status) if err != nil { - dev.t.Fatalf("mocktwilio: error setting SMS status %s to %s: %v", status, msg.To(), err) + dev.t.Fatalf("mocktwilio: set status '%s' on %s: %v", status, sms, err) } - dev.t.Log("mocktwilio: received expected SMS from", msg.From(), "to", msg.To(), "with text", msg.Text()) - return &assertSMS{assertDev: dev, Message: msg} + return sms } } } diff --git a/devtools/mocktwilio/callstate.go b/devtools/mocktwilio/callstate.go index 487cad8bce..c152e5677f 100644 --- a/devtools/mocktwilio/callstate.go +++ b/devtools/mocktwilio/callstate.go @@ -3,7 +3,6 @@ package mocktwilio import ( "context" "encoding/json" - "encoding/xml" "fmt" "math" "net/url" @@ -64,6 +63,7 @@ func (s *callState) lifecycle(ctx context.Context) { } } +// Text returns the last spoken text of the call. func (s *callState) Text() string { s.mx.Lock() defer s.mx.Unlock() @@ -99,13 +99,6 @@ func (s *callState) Answer(ctx context.Context) error { return nil } -type Node struct { - XMLName xml.Name - Attrs []xml.Attr `xml:",any,attr"` - Content string `xml:",innerxml"` - Nodes []Node `xml:",any"` -} - func (s *callState) update(ctx context.Context, digits string) error { v := make(url.Values) v.Set("AccountSid", s.srv.cfg.AccountSID) @@ -132,6 +125,7 @@ func (s *callState) update(ctx context.Context, digits string) error { return s.process(ctx) } +// process will interpret the returned TwiML and update the call state and spoken text. func (s *callState) process(ctx context.Context) error { s.text = "" @@ -177,6 +171,7 @@ func (s *callState) status() string { return s.Status } +// setStatus will update the call status, posting to the status callback URL, if provided. func (s *callState) setStatus(ctx context.Context, status string) { s.mx.Lock() defer s.mx.Unlock() diff --git a/devtools/mocktwilio/handlelookup.go b/devtools/mocktwilio/handlelookup.go index 360105d1ae..1550ddac75 100644 --- a/devtools/mocktwilio/handlelookup.go +++ b/devtools/mocktwilio/handlelookup.go @@ -3,8 +3,8 @@ package mocktwilio import ( "encoding/json" "net/http" - "path" "strconv" + "strings" "github.com/ttacon/libphonenumber" ) @@ -20,8 +20,9 @@ type CarrierInfo struct { MobileNC string `json:"mobile_network_code"` } +// HandleLookup is a handler for the Twilio Lookup API at /v1/PhoneNumbers/. func (s *Server) HandleLookup(w http.ResponseWriter, req *http.Request) { - number := path.Base(req.URL.Path) + number := strings.TrimPrefix(req.URL.Path, "/v1/PhoneNumbers/") inclCarrier := req.URL.Query().Get("Type") == "carrier" var info struct { From 9d2e79692510f64e4e30cb689ca91ebe5cc35dd3 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 31 Aug 2022 08:57:20 -0500 Subject: [PATCH 40/46] fix dest --- devtools/mocktwilio/assertcall.go | 2 +- devtools/mocktwilio/assertsms.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/devtools/mocktwilio/assertcall.go b/devtools/mocktwilio/assertcall.go index 2b95b9c827..3258aff8d1 100644 --- a/devtools/mocktwilio/assertcall.go +++ b/devtools/mocktwilio/assertcall.go @@ -12,7 +12,7 @@ type assertCall struct { } func (a *assertions) newAssertCall(baseCall Call) *assertCall { - dev := &assertDev{a, baseCall.From()} + dev := &assertDev{a, baseCall.To()} return dev.newAssertCall(baseCall) } diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go index 8e5bbd32b6..38101761b1 100644 --- a/devtools/mocktwilio/assertsms.go +++ b/devtools/mocktwilio/assertsms.go @@ -13,7 +13,7 @@ type assertSMS struct { } func (a *assertions) newAssertSMS(baseSMS Message) *assertSMS { - dev := &assertDev{a, baseSMS.From()} + dev := &assertDev{a, baseSMS.To()} sms := &assertSMS{ assertDev: dev, Message: baseSMS, @@ -44,7 +44,7 @@ func (dev *assertDev) SendSMS(body string) { _, err := dev.SendMessage(ctx, dev.number, dev.AppPhoneNumber, body) if err != nil { - dev.t.Fatalf("mocktwilio: send SMS %s to %s: %v", strconv.Quote(body), dev.number, err) + dev.t.Fatalf("mocktwilio: send SMS %s from %s to %s: %v", strconv.Quote(body), dev.number, dev.AppPhoneNumber, err) } } From 3dc3fd710050cab193a97588fd824ca43be5e0b1 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 14 Dec 2022 10:30:38 -0600 Subject: [PATCH 41/46] update to Text() method --- test/smoke/twiliosmsrestrictions_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/smoke/twiliosmsrestrictions_test.go b/test/smoke/twiliosmsrestrictions_test.go index 9a81e873f8..072772b2ed 100644 --- a/test/smoke/twiliosmsrestrictions_test.go +++ b/test/smoke/twiliosmsrestrictions_test.go @@ -50,16 +50,16 @@ func TestTwilioSMSRestrictions(t *testing.T) { // US number supports URLs and 2-way SMS sms := tw.Device(h.PhoneCC("+1", "1")).ExpectSMS("testing") - assert.Contains(t, sms.Body(), "ack") - assert.Contains(t, sms.Body(), "http") + assert.Contains(t, sms.Text(), "ack") + assert.Contains(t, sms.Text(), "http") // CN number does not support URLs or 2-way SMS sms = tw.Device(h.PhoneCC("+86", "1")).ExpectSMS("testing") - assert.NotContains(t, sms.Body(), "ack") - assert.NotContains(t, sms.Body(), "http") + assert.NotContains(t, sms.Text(), "ack") + assert.NotContains(t, sms.Text(), "http") // IN supports URLs but not 2-way SMS sms = tw.Device(h.PhoneCC("+91", "1")).ExpectSMS("testing") - assert.NotContains(t, sms.Body(), "ack") - assert.Contains(t, sms.Body(), "http") + assert.NotContains(t, sms.Text(), "ack") + assert.Contains(t, sms.Text(), "http") } From 6857e7c9c8d0668798d3b6b9d4c4240f1b345aac Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 14 Dec 2022 10:51:40 -0600 Subject: [PATCH 42/46] add assertion example --- devtools/mocktwilio/assertions_test.go | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 devtools/mocktwilio/assertions_test.go diff --git a/devtools/mocktwilio/assertions_test.go b/devtools/mocktwilio/assertions_test.go new file mode 100644 index 0000000000..6864049182 --- /dev/null +++ b/devtools/mocktwilio/assertions_test.go @@ -0,0 +1,43 @@ +package mocktwilio_test + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/target/goalert/devtools/mocktwilio" +) + +func TestAssertSMS(t *testing.T) { + s := mocktwilio.NewServer(mocktwilio.Config{ + AccountSID: "AC123", + AuthToken: "abc123", + }) + srv := httptest.NewServer(s) + defer srv.Close() + + s.AddUpdateNumber(mocktwilio.Number{Number: "+12345678901"}) + + a := mocktwilio.NewAssertions(t, mocktwilio.AssertConfig{ + ServerAPI: s, + AppPhoneNumber: "+12345678901", + }) + + v := make(url.Values) + v.Set("Body", "Hello, world!") + v.Set("From", "+12345678901") + v.Set("To", "+23456789012") + resp, err := http.PostForm(srv.URL+"/2010-04-01/Accounts/AC123/Messages.json", v) + require.NoError(t, err) + if !assert.Equal(t, 201, resp.StatusCode) { + data, _ := io.ReadAll(resp.Body) + t.Log(string(data)) + return + } + + a.Device("+23456789012").ExpectSMS("Hello, world!") +} From 999e9959fa20b36526eaa9d6e218e3ea0cf2ad87 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 14 Dec 2022 11:00:52 -0600 Subject: [PATCH 43/46] add examples --- devtools/mocktwilio/assertions_test.go | 16 +++++++++++++++ devtools/mocktwilio/twiml/gather_test.go | 25 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 devtools/mocktwilio/twiml/gather_test.go diff --git a/devtools/mocktwilio/assertions_test.go b/devtools/mocktwilio/assertions_test.go index 6864049182..4842ba4d2e 100644 --- a/devtools/mocktwilio/assertions_test.go +++ b/devtools/mocktwilio/assertions_test.go @@ -12,6 +12,22 @@ import ( "github.com/target/goalert/devtools/mocktwilio" ) +func ExampleServer_AddUpdateNumber() { + s := mocktwilio.NewServer(mocktwilio.Config{ + AccountSID: "AC123", + }) + + s.AddUpdateNumber(mocktwilio.Number{ + Number: "+12345678901", + + // Set these if you want to process INCOMING messages (TO your app number). + // + // usually something like testSrv.URL() + "/your-voice-path" + VoiceWebhookURL: "https://example.com/voice", + SMSWebhookURL: "https://example.com/sms", + }) +} + func TestAssertSMS(t *testing.T) { s := mocktwilio.NewServer(mocktwilio.Config{ AccountSID: "AC123", diff --git a/devtools/mocktwilio/twiml/gather_test.go b/devtools/mocktwilio/twiml/gather_test.go new file mode 100644 index 0000000000..cb7c44185e --- /dev/null +++ b/devtools/mocktwilio/twiml/gather_test.go @@ -0,0 +1,25 @@ +package twiml_test + +import ( + "encoding/xml" + "os" + + "github.com/target/goalert/devtools/mocktwilio/twiml" +) + +func ExampleGather() { + var resp twiml.Response + resp.Verbs = append(resp.Verbs, &twiml.Gather{ + Action: "/gather", + NumDigitsCount: 5, + Verbs: []twiml.GatherVerb{ + &twiml.Say{ + Content: "Please enter your 5-digit zip code.", + }, + }, + }) + + xml.NewEncoder(os.Stdout).Encode(resp) + // Output: + // Please enter your 5-digit zip code. +} From 9431833e04671963880770baf565ce9a88e53237 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 14 Dec 2022 11:59:55 -0600 Subject: [PATCH 44/46] make handler methods private --- devtools/mocktwilio/handlecallstatus.go | 4 ++-- devtools/mocktwilio/handlelookup.go | 4 ++-- devtools/mocktwilio/handlemessagestatus.go | 4 ++-- devtools/mocktwilio/handlenewcall.go | 2 +- devtools/mocktwilio/handlenewmessage.go | 4 ++-- devtools/mocktwilio/http.go | 10 +++++----- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/devtools/mocktwilio/handlecallstatus.go b/devtools/mocktwilio/handlecallstatus.go index 7811706aa1..34dc49a368 100644 --- a/devtools/mocktwilio/handlecallstatus.go +++ b/devtools/mocktwilio/handlecallstatus.go @@ -7,8 +7,8 @@ import ( "strings" ) -// HandleCallStatus handles GET requests to /2010-04-01/Accounts//Calls/.json -func (srv *Server) HandleCallStatus(w http.ResponseWriter, r *http.Request) { +// handleCallStatus handles GET requests to /2010-04-01/Accounts//Calls/.json +func (srv *Server) handleCallStatus(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { respondErr(w, twError{ Status: 405, diff --git a/devtools/mocktwilio/handlelookup.go b/devtools/mocktwilio/handlelookup.go index 1550ddac75..f069a31297 100644 --- a/devtools/mocktwilio/handlelookup.go +++ b/devtools/mocktwilio/handlelookup.go @@ -20,8 +20,8 @@ type CarrierInfo struct { MobileNC string `json:"mobile_network_code"` } -// HandleLookup is a handler for the Twilio Lookup API at /v1/PhoneNumbers/. -func (s *Server) HandleLookup(w http.ResponseWriter, req *http.Request) { +// handleLookup is a handler for the Twilio Lookup API at /v1/PhoneNumbers/. +func (s *Server) handleLookup(w http.ResponseWriter, req *http.Request) { number := strings.TrimPrefix(req.URL.Path, "/v1/PhoneNumbers/") inclCarrier := req.URL.Query().Get("Type") == "carrier" diff --git a/devtools/mocktwilio/handlemessagestatus.go b/devtools/mocktwilio/handlemessagestatus.go index 95582aadf2..1ecbc85d23 100644 --- a/devtools/mocktwilio/handlemessagestatus.go +++ b/devtools/mocktwilio/handlemessagestatus.go @@ -7,8 +7,8 @@ import ( "strings" ) -// HandleMessageStatus handles GET requests to /2010-04-01/Accounts//Messages/.json -func (srv *Server) HandleMessageStatus(w http.ResponseWriter, r *http.Request) { +// handleMessageStatus handles GET requests to /2010-04-01/Accounts//Messages/.json +func (srv *Server) handleMessageStatus(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { respondErr(w, twError{ Status: 405, diff --git a/devtools/mocktwilio/handlenewcall.go b/devtools/mocktwilio/handlenewcall.go index bfc052595e..49034bd3be 100644 --- a/devtools/mocktwilio/handlenewcall.go +++ b/devtools/mocktwilio/handlenewcall.go @@ -9,7 +9,7 @@ import ( ) // HandleNewMessage handles POST requests to /2010-04-01/Accounts//Calls.json -func (srv *Server) HandleNewCall(w http.ResponseWriter, r *http.Request) { +func (srv *Server) handleNewCall(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { respondErr(w, twError{ Status: 405, diff --git a/devtools/mocktwilio/handlenewmessage.go b/devtools/mocktwilio/handlenewmessage.go index 5b7ad71905..7bc0666e8f 100644 --- a/devtools/mocktwilio/handlenewmessage.go +++ b/devtools/mocktwilio/handlenewmessage.go @@ -8,8 +8,8 @@ import ( "strings" ) -// HandleNewMessage handles POST requests to /2010-04-01/Accounts//Messages.json -func (srv *Server) HandleNewMessage(w http.ResponseWriter, r *http.Request) { +// handleNewMessage handles POST requests to /2010-04-01/Accounts//Messages.json +func (srv *Server) handleNewMessage(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { respondErr(w, twError{ Status: 405, diff --git a/devtools/mocktwilio/http.go b/devtools/mocktwilio/http.go index 30cdace61f..823eb2ac0b 100644 --- a/devtools/mocktwilio/http.go +++ b/devtools/mocktwilio/http.go @@ -15,11 +15,11 @@ func (srv *Server) basePath() string { } func (srv *Server) initHTTP() { - srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.HandleNewMessage) - srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.HandleMessageStatus) - srv.mux.HandleFunc(srv.basePath()+"/Calls.json", srv.HandleNewCall) - srv.mux.HandleFunc(srv.basePath()+"/Calls/", srv.HandleCallStatus) - srv.mux.HandleFunc("/v1/PhoneNumbers/", srv.HandleLookup) + srv.mux.HandleFunc(srv.basePath()+"/Messages.json", srv.handleNewMessage) + srv.mux.HandleFunc(srv.basePath()+"/Messages/", srv.handleMessageStatus) + srv.mux.HandleFunc(srv.basePath()+"/Calls.json", srv.handleNewCall) + srv.mux.HandleFunc(srv.basePath()+"/Calls/", srv.handleCallStatus) + srv.mux.HandleFunc("/v1/PhoneNumbers/", srv.handleLookup) } func (s *Server) post(ctx context.Context, url string, v url.Values) ([]byte, error) { From 56a9562fc6434a7d573a8078b5415fb2d36bc9e3 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 14 Dec 2022 12:12:24 -0600 Subject: [PATCH 45/46] move assertions to separate package --- devtools/mocktwilio/assertcall.go | 148 ----------------- devtools/mocktwilio/assertdev.go | 6 - devtools/mocktwilio/assertions.go | 143 ----------------- devtools/mocktwilio/assertsms.go | 130 --------------- devtools/mocktwilio/server_test.go | 16 ++ devtools/mocktwilio/strings.go | 18 --- devtools/mocktwilio/twassert/assertions.go | 110 +++++++++++++ .../{ => twassert}/assertions_test.go | 21 +-- devtools/mocktwilio/twassert/call.go | 150 ++++++++++++++++++ devtools/mocktwilio/twassert/config.go | 19 +++ devtools/mocktwilio/twassert/dev.go | 11 ++ .../{ => twassert}/phoneassertions.go | 22 +-- devtools/mocktwilio/twassert/serverapi.go | 18 +++ devtools/mocktwilio/twassert/sms.go | 132 +++++++++++++++ devtools/mocktwilio/twassert/strings.go | 21 +++ test/smoke/harness/harness.go | 7 +- 16 files changed, 497 insertions(+), 475 deletions(-) delete mode 100644 devtools/mocktwilio/assertcall.go delete mode 100644 devtools/mocktwilio/assertdev.go delete mode 100644 devtools/mocktwilio/assertions.go delete mode 100644 devtools/mocktwilio/assertsms.go create mode 100644 devtools/mocktwilio/twassert/assertions.go rename devtools/mocktwilio/{ => twassert}/assertions_test.go (63%) create mode 100644 devtools/mocktwilio/twassert/call.go create mode 100644 devtools/mocktwilio/twassert/config.go create mode 100644 devtools/mocktwilio/twassert/dev.go rename devtools/mocktwilio/{ => twassert}/phoneassertions.go (87%) create mode 100644 devtools/mocktwilio/twassert/serverapi.go create mode 100644 devtools/mocktwilio/twassert/sms.go create mode 100644 devtools/mocktwilio/twassert/strings.go diff --git a/devtools/mocktwilio/assertcall.go b/devtools/mocktwilio/assertcall.go deleted file mode 100644 index 3258aff8d1..0000000000 --- a/devtools/mocktwilio/assertcall.go +++ /dev/null @@ -1,148 +0,0 @@ -package mocktwilio - -import ( - "context" - "fmt" - "time" -) - -type assertCall struct { - *assertDev - Call -} - -func (a *assertions) newAssertCall(baseCall Call) *assertCall { - dev := &assertDev{a, baseCall.To()} - return dev.newAssertCall(baseCall) -} - -func (dev *assertDev) newAssertCall(baseCall Call) *assertCall { - call := &assertCall{ - assertDev: dev, - Call: baseCall, - } - dev.t.Logf("mocktwilio: incoming %s", call) - return call -} - -func (dev *assertDev) ExpectVoice(keywords ...string) { - dev.t.Helper() - dev.ExpectCall().Answer().ExpectSay(keywords...).Hangup() -} - -// String returns a string representation of the call for test output. -func (call *assertCall) String() string { - return fmt.Sprintf("call from %s to %s", call.From(), call.To()) -} - -// Answer is part of the RingingCall interface. -func (call *assertCall) Answer() ExpectedCall { - call.t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) - defer cancel() - - err := call.Call.Answer(ctx) - if err != nil { - call.t.Fatalf("mocktwilio: answer %s: %v", call, err) - } - - return call -} - -func (call *assertCall) Reject() { call.RejectWith(CallFailed) } - -func (call *assertCall) RejectWith(status FinalCallStatus) { - call.t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) - defer cancel() - - err := call.Call.Hangup(ctx, status) - if err != nil { - call.t.Fatalf("mocktwilio: hangup %s with '%s': %v", call, status, err) - } -} - -func (call *assertCall) Hangup() { - call.t.Helper() - call.RejectWith(CallCompleted) -} - -func (call *assertCall) Press(digits string) ExpectedCall { - call.t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) - defer cancel() - - err := call.Call.Press(ctx, digits) - if err != nil { - call.t.Fatalf("mocktwilio: press '%s' on %s: %v", digits, call, err) - } - - return call -} - -func (call *assertCall) IdleForever() ExpectedCall { - call.t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), call.assertDev.Timeout) - defer cancel() - - err := call.PressTimeout(ctx) - if err != nil { - call.t.Fatalf("mocktwilio: wait on %s: %v", call, err) - } - - return call -} - -func (call *assertCall) ExpectSay(keywords ...string) ExpectedCall { - call.t.Helper() - - if !containsAll(call.Text(), keywords) { - call.t.Fatalf("mocktwilio: expected %s to say: %v, but got: %s", call, keywords, call.Text()) - } - - return call -} - -func (dev *assertDev) ExpectCall() RingingCall { - dev.t.Helper() - - for idx, call := range dev.calls { - if call.To() != dev.number { - continue - } - - // Remove the call from the list of calls. - dev.calls = append(dev.calls[:idx], dev.calls[idx+1:]...) - - return call - } - - dev.refresh() - - t := time.NewTimer(dev.Timeout) - defer t.Stop() - - ref := time.NewTicker(time.Second) - defer ref.Stop() - - for { - select { - case <-t.C: - dev.t.Fatalf("mocktwilio: timeout after %s waiting for a voice call to %s", dev.Timeout, dev.number) - case <-ref.C: - dev.refresh() - case baseCall := <-dev.Calls(): - call := dev.newAssertCall(baseCall) - if call.To() != dev.number { - dev.calls = append(dev.calls, call) - continue - } - - return call - } - } -} diff --git a/devtools/mocktwilio/assertdev.go b/devtools/mocktwilio/assertdev.go deleted file mode 100644 index f46702e09e..0000000000 --- a/devtools/mocktwilio/assertdev.go +++ /dev/null @@ -1,6 +0,0 @@ -package mocktwilio - -type assertDev struct { - *assertions - number string -} diff --git a/devtools/mocktwilio/assertions.go b/devtools/mocktwilio/assertions.go deleted file mode 100644 index b007f7eae4..0000000000 --- a/devtools/mocktwilio/assertions.go +++ /dev/null @@ -1,143 +0,0 @@ -package mocktwilio - -import ( - "context" - "testing" - "time" -) - -type AssertConfig struct { - ServerAPI - // Timeout is used to set the timeout for all operations, expected messages/calls as well as API calls for things like answering a call. - Timeout time.Duration - - // AppPhoneNumber is the phone number that the application will use to make calls and send messages. - AppPhoneNumber string - - // RefreshFunc will be called before waiting for new messages or calls to arrive. - // - // It is useful for testing purposes to ensure pending messages/calls are sent from the application. - // - // Implementations should not return until requests to mocktwilio are complete. - RefreshFunc func() -} - -// ServerAPI is the interface for the mocktwilio server. -type ServerAPI interface { - SendMessage(ctx context.Context, from, to, body string) (Message, error) - - // WaitInFlight should return after all in-flight messages are processed. - WaitInFlight(context.Context) error - - Messages() <-chan Message - Calls() <-chan Call -} - -func NewAssertions(t *testing.T, cfg AssertConfig) PhoneAssertions { - return &assertions{ - t: t, - assertBase: &assertBase{AssertConfig: cfg}, - } -} - -func (a *assertions) WithT(t *testing.T) PhoneAssertions { - return &assertions{ - t: t, - assertBase: a.assertBase, - } -} - -type assertions struct { - t *testing.T - *assertBase -} - -type assertBase struct { - AssertConfig - - messages []*assertSMS - calls []*assertCall - - ignoreSMS []assertIgnore -} - -type assertIgnore struct { - number string - keywords []string -} - -func (a *assertions) matchMessage(destNumber string, keywords []string, t *assertSMS) bool { - a.t.Helper() - if t.To() != destNumber { - return false - } - - return containsAll(t.Text(), keywords) -} - -func (a *assertions) refresh() { - if a.RefreshFunc == nil { - return - } - - a.RefreshFunc() -} - -// Device will allow expecting calls and messages from a particular destination number. -func (a *assertions) Device(number string) PhoneDevice { - return &assertDev{a, number} -} - -// WaitAndAssert will ensure no unexpected messages or calls are received. -func (a *assertions) WaitAndAssert() { - a.t.Helper() - - // flush any remaining application messages - a.refresh() - a.ServerAPI.WaitInFlight(context.Background()) - -drainMessages: - for { - select { - case msg := <-a.Messages(): - sms := a.newAssertSMS(msg) - a.messages = append(a.messages, sms) - default: - break drainMessages - } - } - -drainCalls: - for { - select { - case baseCall := <-a.Calls(): - call := a.newAssertCall(baseCall) - a.calls = append(a.calls, call) - default: - break drainCalls - } - } - - var hasFailure bool - -checkMessages: - for _, msg := range a.messages { - for _, ignore := range a.ignoreSMS { - if a.matchMessage(ignore.number, ignore.keywords, msg) { - continue checkMessages - } - } - - hasFailure = true - a.t.Errorf("mocktwilio: unexpected %s", msg) - } - - for _, call := range a.calls { - hasFailure = true - a.t.Errorf("mocktwilio: unexpected %s", call) - } - - if hasFailure { - a.t.FailNow() - } -} diff --git a/devtools/mocktwilio/assertsms.go b/devtools/mocktwilio/assertsms.go deleted file mode 100644 index 38101761b1..0000000000 --- a/devtools/mocktwilio/assertsms.go +++ /dev/null @@ -1,130 +0,0 @@ -package mocktwilio - -import ( - "context" - "fmt" - "strconv" - "time" -) - -type assertSMS struct { - *assertDev - Message -} - -func (a *assertions) newAssertSMS(baseSMS Message) *assertSMS { - dev := &assertDev{a, baseSMS.To()} - sms := &assertSMS{ - assertDev: dev, - Message: baseSMS, - } - dev.t.Logf("mocktwilio: incoming %s", sms) - return sms -} - -func (sms *assertSMS) String() string { - return fmt.Sprintf("SMS %s from %s to %s", strconv.Quote(sms.Text()), sms.From(), sms.To()) -} - -func (sms *assertSMS) ThenExpect(keywords ...string) ExpectedSMS { - sms.t.Helper() - return sms._ExpectSMS(false, MessageDelivered, keywords...) -} - -func (sms *assertSMS) ThenReply(body string) SMSReply { - sms.SendSMS(body) - return sms -} - -func (dev *assertDev) SendSMS(body string) { - dev.t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) - defer cancel() - - _, err := dev.SendMessage(ctx, dev.number, dev.AppPhoneNumber, body) - if err != nil { - dev.t.Fatalf("mocktwilio: send SMS %s from %s to %s: %v", strconv.Quote(body), dev.number, dev.AppPhoneNumber, err) - } -} - -func (dev *assertDev) ExpectSMS(keywords ...string) ExpectedSMS { - dev.t.Helper() - return dev._ExpectSMS(true, MessageDelivered, keywords...) -} - -func (dev *assertDev) RejectSMS(keywords ...string) { - dev.t.Helper() - dev._ExpectSMS(true, MessageFailed, keywords...) -} - -func (dev *assertDev) ThenExpect(keywords ...string) ExpectedSMS { - dev.t.Helper() - keywords = toLowerSlice(keywords) - - return dev._ExpectSMS(false, MessageDelivered, keywords...) -} - -func (dev *assertDev) IgnoreUnexpectedSMS(keywords ...string) { - dev.ignoreSMS = append(dev.ignoreSMS, assertIgnore{number: dev.number, keywords: keywords}) -} - -func (dev *assertDev) _ExpectSMS(prev bool, status FinalMessageStatus, keywords ...string) *assertSMS { - dev.t.Helper() - - keywords = toLowerSlice(keywords) - if prev { - for idx, sms := range dev.messages { - if !dev.matchMessage(dev.number, keywords, sms) { - continue - } - - ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) - defer cancel() - - err := sms.SetStatus(ctx, status) - if err != nil { - dev.t.Fatalf("mocktwilio: set status '%s' on %s: %v", status, sms, err) - } - - // remove the message from the list of messages - dev.messages = append(dev.messages[:idx], dev.messages[idx+1:]...) - - return sms - } - } - - dev.refresh() - - t := time.NewTimer(dev.Timeout) - defer t.Stop() - - ref := time.NewTicker(time.Second) - defer ref.Stop() - - for { - select { - case <-t.C: - dev.t.Errorf("mocktwilio: timeout after %s waiting for an SMS to %s with keywords: %v", dev.Timeout, dev.number, keywords) - dev.t.FailNow() - case <-ref.C: - dev.refresh() - case baseSMS := <-dev.Messages(): - sms := dev.newAssertSMS(baseSMS) - if !dev.matchMessage(dev.number, keywords, sms) { - dev.messages = append(dev.messages, sms) - continue - } - - ctx, cancel := context.WithTimeout(context.Background(), dev.Timeout) - defer cancel() - - err := sms.SetStatus(ctx, status) - if err != nil { - dev.t.Fatalf("mocktwilio: set status '%s' on %s: %v", status, sms, err) - } - - return sms - } - } -} diff --git a/devtools/mocktwilio/server_test.go b/devtools/mocktwilio/server_test.go index b712431a08..90ab730530 100644 --- a/devtools/mocktwilio/server_test.go +++ b/devtools/mocktwilio/server_test.go @@ -56,6 +56,22 @@ func TestServer_SMS_MG(t *testing.T) { assert.Equal(t, msg.ID(), msgStatus.SID) } +func ExampleServer_AddUpdateNumber() { + s := mocktwilio.NewServer(mocktwilio.Config{ + AccountSID: "AC123", + }) + + s.AddUpdateNumber(mocktwilio.Number{ + Number: "+12345678901", + + // Set these if you want to process INCOMING messages (TO your app number). + // + // usually something like testSrv.URL() + "/your-voice-path" + VoiceWebhookURL: "https://example.com/voice", + SMSWebhookURL: "https://example.com/sms", + }) +} + func TestServer_SMS(t *testing.T) { cfg := mocktwilio.Config{ AccountSID: "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", diff --git a/devtools/mocktwilio/strings.go b/devtools/mocktwilio/strings.go index 932451a8f5..d3a7df3709 100644 --- a/devtools/mocktwilio/strings.go +++ b/devtools/mocktwilio/strings.go @@ -45,21 +45,3 @@ func isValidURL(urlStr string) bool { } return u.Scheme == "http" || u.Scheme == "https" } - -func toLowerSlice(s []string) []string { - for i, a := range s { - s[i] = strings.ToLower(a) - } - return s -} - -func containsAll(body string, vals []string) bool { - body = strings.ToLower(body) - for _, a := range toLowerSlice(vals) { - if !strings.Contains(body, a) { - return false - } - } - - return true -} diff --git a/devtools/mocktwilio/twassert/assertions.go b/devtools/mocktwilio/twassert/assertions.go new file mode 100644 index 0000000000..2a4ba7cb4b --- /dev/null +++ b/devtools/mocktwilio/twassert/assertions.go @@ -0,0 +1,110 @@ +package twassert + +import ( + "context" + "testing" +) + +func NewAssertions(t *testing.T, cfg Config) Assertions { + return &assertions{ + t: t, + assertBase: &assertBase{Config: cfg}, + } +} + +func (a *assertions) WithT(t *testing.T) Assertions { + return &assertions{ + t: t, + assertBase: a.assertBase, + } +} + +type assertions struct { + t *testing.T + *assertBase +} + +type assertBase struct { + Config + + messages []*sms + calls []*call + + ignoreSMS []ignoreRule +} + +type ignoreRule struct { + number string + keywords []string +} + +func (a *assertions) matchMessage(destNumber string, keywords []string, t *sms) bool { + a.t.Helper() + if t.To() != destNumber { + return false + } + + return containsAll(t.Text(), keywords) +} + +func (a *assertions) refresh() { + if a.RefreshFunc == nil { + return + } + + a.RefreshFunc() +} + +// WaitAndAssert will ensure no unexpected messages or calls are received. +func (a *assertions) WaitAndAssert() { + a.t.Helper() + + // flush any remaining application messages + a.refresh() + a.ServerAPI.WaitInFlight(context.Background()) + +drainMessages: + for { + select { + case msg := <-a.Messages(): + sms := a.newAssertSMS(msg) + a.messages = append(a.messages, sms) + default: + break drainMessages + } + } + +drainCalls: + for { + select { + case baseCall := <-a.Calls(): + call := a.newCall(baseCall) + a.calls = append(a.calls, call) + default: + break drainCalls + } + } + + var hasFailure bool + +checkMessages: + for _, msg := range a.messages { + for _, ignore := range a.ignoreSMS { + if a.matchMessage(ignore.number, ignore.keywords, msg) { + continue checkMessages + } + } + + hasFailure = true + a.t.Errorf("mocktwilio: unexpected %s", msg) + } + + for _, call := range a.calls { + hasFailure = true + a.t.Errorf("mocktwilio: unexpected %s", call) + } + + if hasFailure { + a.t.FailNow() + } +} diff --git a/devtools/mocktwilio/assertions_test.go b/devtools/mocktwilio/twassert/assertions_test.go similarity index 63% rename from devtools/mocktwilio/assertions_test.go rename to devtools/mocktwilio/twassert/assertions_test.go index 4842ba4d2e..4a1ec4189a 100644 --- a/devtools/mocktwilio/assertions_test.go +++ b/devtools/mocktwilio/twassert/assertions_test.go @@ -1,4 +1,4 @@ -package mocktwilio_test +package twassert_test import ( "io" @@ -10,24 +10,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/target/goalert/devtools/mocktwilio" + "github.com/target/goalert/devtools/mocktwilio/twassert" ) -func ExampleServer_AddUpdateNumber() { - s := mocktwilio.NewServer(mocktwilio.Config{ - AccountSID: "AC123", - }) - - s.AddUpdateNumber(mocktwilio.Number{ - Number: "+12345678901", - - // Set these if you want to process INCOMING messages (TO your app number). - // - // usually something like testSrv.URL() + "/your-voice-path" - VoiceWebhookURL: "https://example.com/voice", - SMSWebhookURL: "https://example.com/sms", - }) -} - func TestAssertSMS(t *testing.T) { s := mocktwilio.NewServer(mocktwilio.Config{ AccountSID: "AC123", @@ -38,7 +23,7 @@ func TestAssertSMS(t *testing.T) { s.AddUpdateNumber(mocktwilio.Number{Number: "+12345678901"}) - a := mocktwilio.NewAssertions(t, mocktwilio.AssertConfig{ + a := twassert.NewAssertions(t, twassert.Config{ ServerAPI: s, AppPhoneNumber: "+12345678901", }) diff --git a/devtools/mocktwilio/twassert/call.go b/devtools/mocktwilio/twassert/call.go new file mode 100644 index 0000000000..178cd4dd00 --- /dev/null +++ b/devtools/mocktwilio/twassert/call.go @@ -0,0 +1,150 @@ +package twassert + +import ( + "context" + "fmt" + "time" + + "github.com/target/goalert/devtools/mocktwilio" +) + +type call struct { + *dev + mocktwilio.Call +} + +func (a *assertions) newCall(baseCall mocktwilio.Call) *call { + dev := &dev{a, baseCall.To()} + return dev.newCall(baseCall) +} + +func (d *dev) newCall(baseCall mocktwilio.Call) *call { + call := &call{ + dev: d, + Call: baseCall, + } + d.t.Logf("mocktwilio: incoming %s", call) + return call +} + +func (d *dev) ExpectVoice(keywords ...string) { + d.t.Helper() + d.ExpectCall().Answer().ExpectSay(keywords...).Hangup() +} + +// String returns a string representation of the call for test output. +func (c *call) String() string { + return fmt.Sprintf("call from %s to %s", c.From(), c.To()) +} + +// Answer is part of the RingingCall interface. +func (c *call) Answer() ExpectedCall { + c.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), c.dev.Timeout) + defer cancel() + + err := c.Call.Answer(ctx) + if err != nil { + c.t.Fatalf("mocktwilio: answer %s: %v", c, err) + } + + return c +} + +func (c *call) Reject() { c.RejectWith(mocktwilio.CallFailed) } + +func (c *call) RejectWith(status mocktwilio.FinalCallStatus) { + c.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), c.dev.Timeout) + defer cancel() + + err := c.Call.Hangup(ctx, status) + if err != nil { + c.t.Fatalf("mocktwilio: hangup %s with '%s': %v", c, status, err) + } +} + +func (c *call) Hangup() { + c.t.Helper() + c.RejectWith(mocktwilio.CallCompleted) +} + +func (c *call) Press(digits string) ExpectedCall { + c.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), c.dev.Timeout) + defer cancel() + + err := c.Call.Press(ctx, digits) + if err != nil { + c.t.Fatalf("mocktwilio: press '%s' on %s: %v", digits, c, err) + } + + return c +} + +func (c *call) IdleForever() ExpectedCall { + c.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), c.dev.Timeout) + defer cancel() + + err := c.PressTimeout(ctx) + if err != nil { + c.t.Fatalf("mocktwilio: wait on %s: %v", c, err) + } + + return c +} + +func (c *call) ExpectSay(keywords ...string) ExpectedCall { + c.t.Helper() + + if !containsAll(c.Text(), keywords) { + c.t.Fatalf("mocktwilio: expected %s to say: %v, but got: %s", c, keywords, c.Text()) + } + + return c +} + +func (d *dev) ExpectCall() RingingCall { + d.t.Helper() + + for idx, call := range d.calls { + if call.To() != d.number { + continue + } + + // Remove the call from the list of calls. + d.calls = append(d.calls[:idx], d.calls[idx+1:]...) + + return call + } + + d.refresh() + + t := time.NewTimer(d.Timeout) + defer t.Stop() + + ref := time.NewTicker(time.Second) + defer ref.Stop() + + for { + select { + case <-t.C: + d.t.Fatalf("mocktwilio: timeout after %s waiting for a voice call to %s", d.Timeout, d.number) + case <-ref.C: + d.refresh() + case baseCall := <-d.Calls(): + call := d.newCall(baseCall) + if call.To() != d.number { + d.calls = append(d.calls, call) + continue + } + + return call + } + } +} diff --git a/devtools/mocktwilio/twassert/config.go b/devtools/mocktwilio/twassert/config.go new file mode 100644 index 0000000000..df3b437528 --- /dev/null +++ b/devtools/mocktwilio/twassert/config.go @@ -0,0 +1,19 @@ +package twassert + +import "time" + +type Config struct { + ServerAPI + // Timeout is used to set the timeout for all operations, expected messages/calls as well as API calls for things like answering a call. + Timeout time.Duration + + // AppPhoneNumber is the phone number that the application will use to make calls and send messages. + AppPhoneNumber string + + // RefreshFunc will be called before waiting for new messages or calls to arrive. + // + // It is useful for testing purposes to ensure pending messages/calls are sent from the application. + // + // Implementations should not return until requests to mocktwilio are complete. + RefreshFunc func() +} diff --git a/devtools/mocktwilio/twassert/dev.go b/devtools/mocktwilio/twassert/dev.go new file mode 100644 index 0000000000..8e765e019e --- /dev/null +++ b/devtools/mocktwilio/twassert/dev.go @@ -0,0 +1,11 @@ +package twassert + +type dev struct { + *assertions + number string +} + +// Device will allow expecting calls and messages from a particular destination number. +func (a *assertions) Device(number string) Device { + return &dev{a, number} +} diff --git a/devtools/mocktwilio/phoneassertions.go b/devtools/mocktwilio/twassert/phoneassertions.go similarity index 87% rename from devtools/mocktwilio/phoneassertions.go rename to devtools/mocktwilio/twassert/phoneassertions.go index b49956f75e..ed0ff7323d 100644 --- a/devtools/mocktwilio/phoneassertions.go +++ b/devtools/mocktwilio/twassert/phoneassertions.go @@ -1,23 +1,27 @@ -package mocktwilio +package twassert -import "testing" +import ( + "testing" -// PhoneAssertions is used to assert voice and SMS behavior. -type PhoneAssertions interface { + "github.com/target/goalert/devtools/mocktwilio" +) + +// Assertions is used to assert voice and SMS behavior. +type Assertions interface { // Device returns a TwilioDevice for the given number. // // It is safe to call multiple times for the same device. - Device(number string) PhoneDevice + Device(number string) Device // WaitAndAssert will fail the test if there are any unexpected messages received. WaitAndAssert() // WithT will return a new PhoneAssertions with a separate text context. - WithT(*testing.T) PhoneAssertions + WithT(*testing.T) Assertions } -// A PhoneDevice immitates a device (i.e. a phone) for testing interactions. -type PhoneDevice interface { +// A Device immitates a device (i.e. a phone) for testing interactions. +type Device interface { // SendSMS will send a message to GoAlert from the device. SendSMS(text string) @@ -43,7 +47,7 @@ type PhoneDevice interface { type RingingCall interface { Answer() ExpectedCall Reject() - RejectWith(FinalCallStatus) + RejectWith(mocktwilio.FinalCallStatus) } // ExpectedCall represents a phone call. diff --git a/devtools/mocktwilio/twassert/serverapi.go b/devtools/mocktwilio/twassert/serverapi.go new file mode 100644 index 0000000000..318a12155c --- /dev/null +++ b/devtools/mocktwilio/twassert/serverapi.go @@ -0,0 +1,18 @@ +package twassert + +import ( + "context" + + "github.com/target/goalert/devtools/mocktwilio" +) + +// ServerAPI is the interface for the mocktwilio server. +type ServerAPI interface { + SendMessage(ctx context.Context, from, to, body string) (mocktwilio.Message, error) + + // WaitInFlight should return after all in-flight messages are processed. + WaitInFlight(context.Context) error + + Messages() <-chan mocktwilio.Message + Calls() <-chan mocktwilio.Call +} diff --git a/devtools/mocktwilio/twassert/sms.go b/devtools/mocktwilio/twassert/sms.go new file mode 100644 index 0000000000..e38671dac8 --- /dev/null +++ b/devtools/mocktwilio/twassert/sms.go @@ -0,0 +1,132 @@ +package twassert + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/target/goalert/devtools/mocktwilio" +) + +type sms struct { + *dev + mocktwilio.Message +} + +func (a *assertions) newAssertSMS(baseSMS mocktwilio.Message) *sms { + d := &dev{a, baseSMS.To()} + sms := &sms{ + dev: d, + Message: baseSMS, + } + d.t.Logf("mocktwilio: incoming %s", sms) + return sms +} + +func (s *sms) String() string { + return fmt.Sprintf("SMS %s from %s to %s", strconv.Quote(s.Text()), s.From(), s.To()) +} + +func (s *sms) ThenExpect(keywords ...string) ExpectedSMS { + s.t.Helper() + return s._ExpectSMS(false, mocktwilio.MessageDelivered, keywords...) +} + +func (s *sms) ThenReply(body string) SMSReply { + s.SendSMS(body) + return s +} + +func (d *dev) SendSMS(body string) { + d.t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + _, err := d.SendMessage(ctx, d.number, d.AppPhoneNumber, body) + if err != nil { + d.t.Fatalf("mocktwilio: send SMS %s from %s to %s: %v", strconv.Quote(body), d.number, d.AppPhoneNumber, err) + } +} + +func (d *dev) ExpectSMS(keywords ...string) ExpectedSMS { + d.t.Helper() + return d._ExpectSMS(true, mocktwilio.MessageDelivered, keywords...) +} + +func (d *dev) RejectSMS(keywords ...string) { + d.t.Helper() + d._ExpectSMS(true, mocktwilio.MessageFailed, keywords...) +} + +func (d *dev) ThenExpect(keywords ...string) ExpectedSMS { + d.t.Helper() + keywords = toLowerSlice(keywords) + + return d._ExpectSMS(false, mocktwilio.MessageDelivered, keywords...) +} + +func (d *dev) IgnoreUnexpectedSMS(keywords ...string) { + d.ignoreSMS = append(d.ignoreSMS, ignoreRule{number: d.number, keywords: keywords}) +} + +func (d *dev) _ExpectSMS(prev bool, status mocktwilio.FinalMessageStatus, keywords ...string) *sms { + d.t.Helper() + + keywords = toLowerSlice(keywords) + if prev { + for idx, sms := range d.messages { + if !d.matchMessage(d.number, keywords, sms) { + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + err := sms.SetStatus(ctx, status) + if err != nil { + d.t.Fatalf("mocktwilio: set status '%s' on %s: %v", status, sms, err) + } + + // remove the message from the list of messages + d.messages = append(d.messages[:idx], d.messages[idx+1:]...) + + return sms + } + } + + d.refresh() + + t := time.NewTimer(d.Timeout) + defer t.Stop() + + ref := time.NewTicker(time.Second) + defer ref.Stop() + + for { + select { + case <-t.C: + d.t.Errorf("mocktwilio: timeout after %s waiting for an SMS to %s with keywords: %v", d.Timeout, d.number, keywords) + d.t.FailNow() + case <-ref.C: + d.refresh() + case baseSMS := <-d.Messages(): + sms := d.newAssertSMS(baseSMS) + if !d.matchMessage(d.number, keywords, sms) { + d.messages = append(d.messages, sms) + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + err := sms.SetStatus(ctx, status) + if err != nil { + d.t.Fatalf("mocktwilio: set status '%s' on %s: %v", status, sms, err) + } + + return sms + } + } +} diff --git a/devtools/mocktwilio/twassert/strings.go b/devtools/mocktwilio/twassert/strings.go new file mode 100644 index 0000000000..0033d292e7 --- /dev/null +++ b/devtools/mocktwilio/twassert/strings.go @@ -0,0 +1,21 @@ +package twassert + +import "strings" + +func toLowerSlice(s []string) []string { + for i, a := range s { + s[i] = strings.ToLower(a) + } + return s +} + +func containsAll(body string, vals []string) bool { + body = strings.ToLower(body) + for _, a := range toLowerSlice(vals) { + if !strings.Contains(body, a) { + return false + } + } + + return true +} diff --git a/test/smoke/harness/harness.go b/test/smoke/harness/harness.go index 013d554169..c92daa699c 100644 --- a/test/smoke/harness/harness.go +++ b/test/smoke/harness/harness.go @@ -29,6 +29,7 @@ import ( "github.com/target/goalert/config" "github.com/target/goalert/devtools/mockslack" "github.com/target/goalert/devtools/mocktwilio" + "github.com/target/goalert/devtools/mocktwilio/twassert" "github.com/target/goalert/devtools/pgdump-lite" "github.com/target/goalert/devtools/pgmocktime" "github.com/target/goalert/migrate" @@ -73,7 +74,7 @@ type Harness struct { msgSvcID string - tw mocktwilio.PhoneAssertions + tw twassert.Assertions mockTw *mocktwilio.Server twS *httptest.Server @@ -101,7 +102,7 @@ type Harness struct { gqlSessions map[string]string } -func (h *Harness) Twilio(t *testing.T) mocktwilio.PhoneAssertions { return h.tw.WithT(t) } +func (h *Harness) Twilio(t *testing.T) twassert.Assertions { return h.tw.WithT(t) } func (h *Harness) Config() config.Config { return h.cfg @@ -286,7 +287,7 @@ func (h *Harness) Start() { if err != nil { h.t.Fatalf("failed to start backend: %v", err) } - h.tw = mocktwilio.NewAssertions(h.t, mocktwilio.AssertConfig{ + h.tw = twassert.NewAssertions(h.t, twassert.Config{ ServerAPI: h.mockTw, Timeout: 15 * time.Second, AppPhoneNumber: h.TwilioNumber(""), From 979427f0c21c32ab7cae16a3da99820bfe7fa9c1 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Wed, 14 Dec 2022 12:16:45 -0600 Subject: [PATCH 46/46] update docs --- .../twassert/{phoneassertions.go => assertionapi.go} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename devtools/mocktwilio/twassert/{phoneassertions.go => assertionapi.go} (93%) diff --git a/devtools/mocktwilio/twassert/phoneassertions.go b/devtools/mocktwilio/twassert/assertionapi.go similarity index 93% rename from devtools/mocktwilio/twassert/phoneassertions.go rename to devtools/mocktwilio/twassert/assertionapi.go index ed0ff7323d..9e12fdb2d0 100644 --- a/devtools/mocktwilio/twassert/phoneassertions.go +++ b/devtools/mocktwilio/twassert/assertionapi.go @@ -8,7 +8,7 @@ import ( // Assertions is used to assert voice and SMS behavior. type Assertions interface { - // Device returns a TwilioDevice for the given number. + // Device returns a Device for the given number. // // It is safe to call multiple times for the same device. Device(number string) Device @@ -16,7 +16,7 @@ type Assertions interface { // WaitAndAssert will fail the test if there are any unexpected messages received. WaitAndAssert() - // WithT will return a new PhoneAssertions with a separate text context. + // WithT will return a new Assertions with a separate test context. WithT(*testing.T) Assertions } @@ -55,7 +55,7 @@ type ExpectedCall interface { // Press imitates a user entering a key on the phone. Press(digits string) ExpectedCall - // IdleForever imitates a user waiting for a timeout (without pressing anything) on the phone. + // IdleForever imitates a user waiting for a timeout (without pressing anything) on the phone. It can be used to timeout a Gather. IdleForever() ExpectedCall // ExpectSay asserts that the spoken message matches ALL keywords (case-insensitive).