twilio-test-toolkit-alt 3.4.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +265 -0
  4. data/Rakefile +38 -0
  5. data/lib/twilio-test-toolkit.rb +4 -0
  6. data/lib/twilio-test-toolkit/call_in_progress.rb +48 -0
  7. data/lib/twilio-test-toolkit/call_scope.rb +247 -0
  8. data/lib/twilio-test-toolkit/dsl.rb +15 -0
  9. data/lib/twilio-test-toolkit/version.rb +3 -0
  10. data/spec/dummy/README.rdoc +261 -0
  11. data/spec/dummy/Rakefile +7 -0
  12. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  13. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  14. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  15. data/spec/dummy/app/controllers/twilio_controller.rb +12 -0
  16. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  17. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  18. data/spec/dummy/app/views/layouts/twilio.layout.xml.erb +4 -0
  19. data/spec/dummy/app/views/twilio/test_action.xml.erb +2 -0
  20. data/spec/dummy/app/views/twilio/test_call_status.xml.erb +6 -0
  21. data/spec/dummy/app/views/twilio/test_dial_with_action.xml.erb +3 -0
  22. data/spec/dummy/app/views/twilio/test_dial_with_no_action.xml.erb +3 -0
  23. data/spec/dummy/app/views/twilio/test_dial_with_sip.xml +6 -0
  24. data/spec/dummy/app/views/twilio/test_gather_finish_on_asterisk.xml.erb +3 -0
  25. data/spec/dummy/app/views/twilio/test_hangup.xml.erb +1 -0
  26. data/spec/dummy/app/views/twilio/test_play.xml.erb +1 -0
  27. data/spec/dummy/app/views/twilio/test_record.xml +2 -0
  28. data/spec/dummy/app/views/twilio/test_redirect.xml.erb +1 -0
  29. data/spec/dummy/app/views/twilio/test_say.xml.erb +1 -0
  30. data/spec/dummy/app/views/twilio/test_start.xml.erb +5 -0
  31. data/spec/dummy/config.ru +4 -0
  32. data/spec/dummy/config/application.rb +59 -0
  33. data/spec/dummy/config/boot.rb +10 -0
  34. data/spec/dummy/config/database.yml +25 -0
  35. data/spec/dummy/config/environment.rb +5 -0
  36. data/spec/dummy/config/environments/development.rb +37 -0
  37. data/spec/dummy/config/environments/production.rb +67 -0
  38. data/spec/dummy/config/environments/test.rb +37 -0
  39. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  40. data/spec/dummy/config/initializers/inflections.rb +15 -0
  41. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  42. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  43. data/spec/dummy/config/initializers/session_store.rb +8 -0
  44. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  45. data/spec/dummy/config/locales/en.yml +5 -0
  46. data/spec/dummy/config/routes.rb +17 -0
  47. data/spec/dummy/db/development.sqlite3 +0 -0
  48. data/spec/dummy/db/schema.rb +16 -0
  49. data/spec/dummy/db/test.sqlite3 +0 -0
  50. data/spec/dummy/log/test.log +20546 -0
  51. data/spec/dummy/public/404.html +26 -0
  52. data/spec/dummy/public/422.html +26 -0
  53. data/spec/dummy/public/500.html +25 -0
  54. data/spec/dummy/public/favicon.ico +0 -0
  55. data/spec/dummy/script/rails +6 -0
  56. data/spec/requests/call_scope_spec.rb +399 -0
  57. data/spec/requests/dsl_spec.rb +69 -0
  58. data/spec/spec_helper.rb +20 -0
  59. metadata +264 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: c73f30d2fade63714f50f0756b88fd508a9dcba4
4
+ data.tar.gz: 43b953b75d0478315ca8fe526193792fb22c8087
5
+ SHA512:
6
+ metadata.gz: 14e466adab316ebb363c53746af1641fe6e9310316f64357910369becb3800763f6a9c01ed5ab8851e58d383f23664c979cd8ea7c0360efd51988acc132bff8a
7
+ data.tar.gz: daac1ceb3d2906f5170bd7078f2238bab695f086fd0822003b65c1051902392b7273a661b91977e34138a48b470d49d751ab60410e8460d00cc7c081b234b3e4
@@ -0,0 +1,20 @@
1
+ Copyright 2012 YOURNAME
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,265 @@
1
+ twilio-test-toolkit
2
+ ===================
3
+
4
+ Twilio Test Toolkit (TTT) makes RSpec integration tests for Twilio phone callbacks easy to write and understand.
5
+
6
+ When you initiate a [phone call with Twilio](http://www.twilio.com/docs/api/rest/making-calls), you must POST either a URL or an ApplicationSid that is configured with a URL. Twilio then POSTs to your URL, and you're expected to return a 200 OK and a valid [TwiML](http://www.twilio.com/docs/api/twiml) response. That response can be any valid TwiML - speak something, gather keystrokes, redirect, etc.
7
+
8
+ Although it's pretty easy to test individual controller actions with the existing RSpec gem, testing more complex scenarios that use many controller actions (or controllers) is syntax-heavy and usually repetitive. TTT exists to make these larger scale integration tests easy and fun to write. TTT emulates the Twilio end of the Twilo phone callbacks, and allows to to emulate a user listening for a specific Say element, pressing keys on their phone, etc. With TTT, you can test your whole phone system built on Twilio.
9
+
10
+ For instance, let's say you have a controller that handles inbound Twilio calls and asks the user to enter an account number, then a PIN code, and then finally makes a menu selection. With TTT, you can just do this:
11
+
12
+ @call = ttt_call(some_controller_action_path, from_number, to_number)
13
+
14
+ @call.should have_say("Welcome to AwesomeCo.")
15
+ @call.within_gather do |gather|
16
+ gather.should have_say("Please enter your account number, followed by the pound sign.")
17
+ gather.press "12345678"
18
+ end
19
+
20
+ @call.current_path.should == some_other_controller_action_path
21
+ @call.within_gather do |gather|
22
+ gather.should have_say("Please enter your 4 digit PIN code.")
23
+ gather.press "9876"
24
+ end
25
+
26
+ @call.current_path.should == yet_another_controller_action_path
27
+ @call.should have_say "Please choose from one of the following menu options"
28
+
29
+ TTT was originally built to handle testing a complex, large-scale phone tree system. Without TTT, it was necessary to do lots of deployments to staging to test how the controller actions worked together, and there wasn't a good automated way to verify any new changes. When new functionality was introduced, TTT tests were written for the full project, and everything that worked in TTT worked great the first time it was pushed to staging. It's a great timesaver.
30
+
31
+ What's supported
32
+ ================
33
+
34
+ TTT supports most of the more common Twilio scenarios:
35
+
36
+ * Checking for Say elements and their content
37
+ * Checking for Play elements and their content
38
+ * Taking action on Gather elements and querying their contents
39
+ * Following and querying redirects
40
+ * Dial and Hangup
41
+
42
+ TTT doesn't yet support (but you should contribute them!)
43
+
44
+ * Queue
45
+ * Any of the other conference calling features
46
+
47
+ What's required
48
+ ================
49
+
50
+ TTT depends on [Capybara](https://github.com/jnicklas/capybara). It uses Capybara's session object to make requests to your controllers.
51
+
52
+ TTT expects your controller actions to behave like well-behaved Twilio callbacks. That is, you need to respond to XML-formatted requests, and need to respond with a 200 OK. Twilio will not follow 301 or 302 redirects properly, and neither will TTT (see below for more details).
53
+
54
+ TTT has been tested with RSpec on Rails, and limited testing has been done with Sinatra. It might work on other test frameworks or other Rack-based frameworks. Feel free to submit pull requests to improve compatibility with these.
55
+
56
+ If it works with Twilio, it should work with TTT. If not, open an issue/pull request.
57
+
58
+ Getting started
59
+ ================
60
+
61
+ First, get RSpec and Capybara working for your project. Then, you'll need to add this to your gemfile:
62
+
63
+ group :test do
64
+ ...
65
+ gem 'twilio-test-toolkit'
66
+ ...
67
+ end
68
+
69
+ Since TTT is test-only code, it should be in the :test group.
70
+
71
+ You'll have to make one more change in spec/spec_helper.rb. If you are using Capybara 1.x, do this:
72
+
73
+ RSpec.configure do |config|
74
+ ...
75
+ # Configure Twilio Test Toolkit
76
+ config.include TwilioTestToolkit::DSL, :type => :request
77
+ ...
78
+ end
79
+
80
+ For Capybara 2.0, the "requests" directory is now called "features", so you need this instead:
81
+
82
+ RSpec.configure do |config|
83
+ ...
84
+ # Configure Twilio Test Toolkit
85
+ config.include TwilioTestToolkit::DSL, :type => :feature
86
+ ...
87
+ end
88
+
89
+ This line is required in order to get TTT's DSL to work with your tests.
90
+
91
+ Finally, since TTT deals with integration tests, you should write your tests in spec/requests (or whatever directory you've configured for this type of test).
92
+
93
+ How to use
94
+ ================
95
+
96
+ This section describes how to use TTT and its basic functionality.
97
+
98
+ ttt_call
99
+ -------------
100
+
101
+ The *ttt_call* method is the main entry point for working with TTT. You call this method to initiate a "Twilio phone call" to your controller actions. TTT simulates Twilio's behavior by making requests to your action with the expected Twilio parameters (From, To, CallSid, etc).
102
+
103
+ *ttt_call* has three required parameters and an options hash:
104
+
105
+ @call = ttt_call(action_path, from_number, to_number, options = {})
106
+
107
+ * **action_path**. Where to make your request. By default this will be a POST, but you can override it with the options hash.
108
+ * **from_number**. What to fill params[:From] with. If you don't care about this value, you can pass a blank one, as this is only used to pass along to your actions.
109
+ * **to_number**. What to fill params[:To] with.
110
+
111
+ Options are:
112
+
113
+ * **method** . Specify the http method of the initial request. By default this will be :post.
114
+ * **call_sid**. Specify an optional fixed value to be passed as params[:CallSid]. This is useful if you are expecting a specific SID. For instance, a common pattern is to initiate a call, store the SID in your database, and look up the call when you get the callback. If you don't pass a SID, TTT will generate one for you that's just a UUID.
115
+ * **is_machine**. Controls params[:AnsweredBy]. See Twilio's documentation for more information on how Twilio uses this.
116
+ * **direction**. Controls params[:Direction]. Should be: inbound, outbound-api, or outbound-dial
117
+ * **called**. Controls params[:Called]. This is present in a REST API-initiated call.
118
+ * **call_status**. Controls params[:CallStatus]. Defaults to `in-progress`. See [help article](https://www.twilio.com/help/faq/voice/what-do-the-call-statuses-mean) on call statuses.
119
+
120
+ *ttt_call* returns a *TwilioTestToolkit::CallInProgress* object, which is a descendent of *TwilioTestToolkit::CallScope*. You'll want to save this object as it's how you interact with TTT.
121
+
122
+ It's worth noting that TTT won't pass any of the parameters you need to [validate that a request comes from Twilio](http://www.twilio.com/docs/security). Most people seem to only do this check in production, so if that applies to you, this won't be an issue. If you do this check in dev and test, you might want to consider submitting a pull request with a fix.
123
+
124
+ Getting call information
125
+ --------------
126
+
127
+ You can use the CallInProgress object returned from *ttt_call* to inspect some basic properties about the call:
128
+
129
+ @call.sid # Returns the SID of the call
130
+ @call.initial_path # Returns the path that you used to start the call (the first parameter to ttt_call)
131
+ @call.from_number # Returns the from number
132
+ @call.to_number # Returns the to number
133
+ @call.is_machine # Returns the answering machine state (passed to ttt_call)
134
+ @call.direction # Returns the direction of the call (inbound, outbound-api, outbound-dial)
135
+ @call.called # Returns the Called number in the case of an outbound-api call
136
+
137
+ Call scopes
138
+ --------------
139
+
140
+ The CallInProgress object returned from *ttt_call* is a descendent of CallScope. A CallScope represents a scope within a call. For instance, the root TwiML Response element is a scope. A Gather within that is a scope.
141
+
142
+ For instance, let's say you have TwiML like this:
143
+
144
+ <Response>
145
+ <Say>Foo</Say>
146
+ <Gather action="baz">
147
+ <Say>Bar</Say>
148
+ </Gather>
149
+ </Response>
150
+
151
+ The scope referred to by the CallInProgress object is the Response object. Only items that directly descend from this scope are seen by TTT. That is, the say for "Foo" is in the scope, but the say for "Bar" is not. The Gather is its own scope, and it contains the say for "Bar". TTT intentionally restricts you to accessing only what's in your scope because it helps you enforce a more rigid structure in your call, and allows you to handle multiple Gathers or similar in a given TwiML markup.
152
+
153
+ CallScope has some properties that are also useful:
154
+
155
+ @call.current_path # Returns the current path that the call is on.
156
+ @call.response_xml # Returns the raw XML response that your action returned to TTT
157
+ @call.root_call # Returns the original CallInProgress returned from ttt_call.
158
+
159
+ Inspecting the contents of the call
160
+ --------------
161
+
162
+ A common thing you'll want to do is inspect the various Say elements, and check for control elements like Dial and Hangup.
163
+
164
+ @call.has_say?("Foo") # Returns true if there's a <Say> in the current scope
165
+ # that contains the text. Partial matches are OK, but
166
+ # the call is case sensitive.
167
+ @call.has_dial?("911") # Returns true if there's a <Dial> in the current scope
168
+ # for the number. Partial matches are OK.
169
+ @call.has_hangup? # Returns true if there's a <Hangup> in the current scope.
170
+
171
+ You can check the existence of any element by using the `#has_foo?` pattern
172
+ where `foo` is your element. Pass in an optional string to check that the string
173
+ is found in the element's inner text:
174
+
175
+ @call.has_sms?("Please make sure you foo the bar.")
176
+
177
+ You can also check that a given attribute exists on any element using
178
+ a `#has_bar_on_foo?` pattern:
179
+
180
+ @call.has_from_on_sms?("+18885551234")
181
+
182
+ Or for camel case attributes you can do either of the following:
183
+
184
+ @call.has_finishOnKey_on_record?("#")
185
+ @call.has_finish_on_key_on_record?("#")
186
+
187
+ These methods are available on any CallScope.
188
+
189
+ Gathers
190
+ --------------
191
+
192
+ Gathers are used to collect digits from the caller (e.g. "press 1 to speak with a representative, press 2 to cancel"). Twilio handles Gathers by speaking the contents of the Gather and waiting for a digit or digits to be pressed. If nothing is pressed, it continues with the script. If something is pressed, it aborts processing the current script and makes a request with the digits (via params[:Digits]) to the path specified by the action attribute. If no method is specified in the options, this will be a post TTT handles Gathers in a similar way.
193
+
194
+ You can only interact with a gather by calling *within_gather*:
195
+
196
+ @call.within_gather do |gather|
197
+ gather.should have_say("Enter your account number")
198
+ gather.press "12345"
199
+ end
200
+
201
+ *within_gather* creates a new CallScope and passes it to the yielded parameter (gather in the example above). *within_gather* will fail if there is no Gather element in the current scope.
202
+
203
+ You can verify the existence of a Gather in the current scope with:
204
+
205
+ @call.has_gather? # Returns true if the current scope has a gather.
206
+
207
+ Within a gather CallScope, you can use the following methods:
208
+
209
+ @call.gather? # Returns true if the current scope **is** a gather. Compare with the has_gather? method.
210
+ @call.gather_action # Returns the value of the action attribute for the gather.
211
+ @call.press("1") # Simulates pressing the specified digits.
212
+
213
+ You can also use other CallScope methods (e.g. *has_say?* and similar.)
214
+
215
+ The *press* method has a few caveats worth knowing. It's only callable once per gather - when you call it, TTT will immediately call the gather's action. There is no way to simulate pressing buttons slowly, and you don't really need to do this anyways - Twilio doesn't care and just passes them all at once. *press* simply fills out the value of params[:Digits] and calls your method, just like Twilio does.
216
+
217
+ Although you can technically pass whatever you want to *press*, in practice Twilio only sends digits and #. Still it's probably a good idea to test garbage data in this parameter with your actions, so TTT doesn't get in your way if you want to call press with "UNICORNSANDPONIES" as a parameter.
218
+
219
+ TTT doesn't attempt to validate your TwiML, so it's worth knowing that Gather only allows Say, Pause, and Play as child elements. Nested Gathers are not supported.
220
+
221
+ Redirects
222
+ --------------
223
+
224
+ The Redirect element is used to tell Twilio to call a different page. It differs from a standard 301 or 302 redirect (created by a *redirect_to*) in that the 301/302 redirects don't support a POST, and if you try a *redirect_to* within a Twilio action, Twilio will fail and your caller will get the dreaded "I'm sorry, an application error has occurred" message. Because Twilio doesn't support 301/302 redirects, TTT doesn't either, and if you use one, TTT will complain.
225
+
226
+ There are several methods you can use within a CallScope related to redirects:
227
+
228
+ @call.has_redirect? # Returns true if there's a <Redirect> element in the current scope.
229
+ @call.has_redirect_to?(path) # Returns true if there's a <Redirect> element to the specified path
230
+ # in the current scope.
231
+ @call.follow_redirect # Follows the <Redirect> in the current scope and returns a new
232
+ # CallScope object. The original scope is not modified.
233
+ @call.follow_redirect! # Follows the <Redirect> in the current scope and updates the scope
234
+ # to the new path.
235
+
236
+ Although it's allowed by TwiML (as of this writing), multiple Redirects in a scope aren't effectively allowed, as only the first one will ever be used. Thus, TTT only looks at the first Redirect it finds in the given scope.
237
+
238
+ Contributing
239
+ ================
240
+
241
+ TTT is pretty basic, but it should work for most people's needs. You might consider helping to improve it. Some things that could be done:
242
+
243
+ * Support more Twilio functionality
244
+ * Build more stringent checks into says and plays - e.g. verify that there's enough of a pause between sentences.
245
+ * Build checks to make sure that numbers, dates, and other special text is spoken properly (e.g. "one two three" instead of "one hundred twenty three").
246
+ * Build checks to validate the correctness of your TwiML.
247
+ * Refactor wonky code (there's probably some in there somewhere)
248
+ * Write more tests. There are basic tests, but more involved ones might be nice
249
+ * Add support as needed for other test frameworks or Rack-based frameworks
250
+
251
+ Contributions are welcome and encouraged. The usual deal applies - fork a branch, add tests, add your changes, submit a pull request. If I haven't done anything with your pull request in a reasonable amount of time, ping me on Twitter or email and I'll get on it.
252
+
253
+ Running Tests
254
+ ----------------
255
+
256
+ bundle install
257
+ cd spec/dummy
258
+ bundle exec rake db:create
259
+ cd ../..
260
+ bundle exec rspec
261
+
262
+ Credits
263
+ ================
264
+
265
+ TTT was put together by Jack Nichols [@jmongol](http://twitter.com/jmongol). MIT license.
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+ begin
8
+ require 'rdoc/task'
9
+ rescue LoadError
10
+ require 'rdoc/rdoc'
11
+ require 'rake/rdoctask'
12
+ RDoc::Task = Rake::RDocTask
13
+ end
14
+
15
+ RDoc::Task.new(:rdoc) do |rdoc|
16
+ rdoc.rdoc_dir = 'rdoc'
17
+ rdoc.title = 'TwilioTestToolkit'
18
+ rdoc.options << '--line-numbers'
19
+ rdoc.rdoc_files.include('README.rdoc')
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+
24
+
25
+
26
+ Bundler::GemHelper.install_tasks
27
+
28
+ require 'rake/testtask'
29
+
30
+ Rake::TestTask.new(:test) do |t|
31
+ t.libs << 'lib'
32
+ t.libs << 'test'
33
+ t.pattern = 'test/**/*_test.rb'
34
+ t.verbose = false
35
+ end
36
+
37
+
38
+ task :default => :test
@@ -0,0 +1,4 @@
1
+ module TwilioTestToolkit
2
+ require "uuidtools"
3
+ require 'twilio-test-toolkit/dsl'
4
+ end
@@ -0,0 +1,48 @@
1
+ require "twilio-test-toolkit/call_scope"
2
+
3
+ module TwilioTestToolkit
4
+ # Models a call
5
+ class CallInProgress < CallScope
6
+ attr_reader :options
7
+ attr_reader :sid, :initial_path, :http_method
8
+ attr_reader :from_number, :to_number
9
+ attr_reader :is_machine, :called, :direction
10
+
11
+ # Initiate a call. Options:
12
+ # * :method - specify the http method of the initial api call
13
+ # * :call_sid - specify an optional fixed value to be passed as params[:CallSid]
14
+ # * :is_machine - controls params[:AnsweredBy]
15
+ def initialize(initial_path, from_number, to_number, options = {})
16
+ default_options = {
17
+ :method => :post,
18
+ :direction => 'inbound',
19
+ :is_machine => false
20
+ }
21
+
22
+ @options = default_options.merge(options)
23
+
24
+ # Save our variables for later
25
+ @initial_path = initial_path
26
+ @from_number = from_number
27
+ @to_number = to_number
28
+ @is_machine = @options[:is_machine]
29
+ @called = @options[:called]
30
+ @direction = @options[:direction]
31
+ @http_method = @options[:method]
32
+
33
+ # Generate an initial call SID if we don't have one
34
+ if (options[:call_sid].nil?)
35
+ @sid = UUIDTools::UUID.random_create.to_s
36
+ else
37
+ @sid = options[:call_sid]
38
+ end
39
+
40
+ # We are the root call
41
+ self.root_call = self
42
+
43
+ # Create the request
44
+ request_for_twiml!(@initial_path, @options)
45
+ end
46
+
47
+ end
48
+ end
@@ -0,0 +1,247 @@
1
+ module TwilioTestToolkit
2
+ # Models a scope within a call.
3
+ class CallScope
4
+
5
+ # Note that el is case sensitive and must match the desired
6
+ # TwiML element. eg. Play (correct) vs play (incorrect).
7
+ def self.has_element(el, options = {})
8
+ define_method "has_#{el.downcase}?" do |inner = nil|
9
+ return has_element?(el, inner, options)
10
+ end
11
+ end
12
+
13
+ # method_missing? will take care of most cases, but for elements
14
+ # where the preference is to have exact matching, just add a
15
+ # definition like:
16
+ #
17
+ # has_element "Foo", :exact_inner_match => true
18
+ has_element "Play", :exact_inner_match => true
19
+
20
+ # Stuff for redirects
21
+ def has_redirect_to?(url)
22
+ el = get_redirect_node
23
+ return false if el.nil?
24
+ return normalize_redirect_path(el.text) == normalize_redirect_path(url)
25
+ end
26
+
27
+ def follow_redirect(options = {})
28
+ el = get_redirect_node
29
+ raise "No redirect" if el.nil?
30
+
31
+ return CallScope.from_request(self, el.text, { :method =>el[:method]}.merge(options))
32
+ end
33
+
34
+ def follow_redirect!(options = {})
35
+ el = get_redirect_node
36
+ raise "No redirect" if el.nil?
37
+
38
+ request_for_twiml!(normalize_redirect_path(el.text), { :method => el[:method] }.merge(options))
39
+ end
40
+
41
+ # Stuff for gatherers
42
+ def gather?
43
+ @xml.name == "Gather"
44
+ end
45
+
46
+ def gather_action
47
+ raise "Not a gather" unless gather?
48
+ return @xml["action"]
49
+ end
50
+
51
+ def gather_method
52
+ raise "Not a gather" unless gather?
53
+ return @xml["method"]
54
+ end
55
+
56
+ def gather_finish_on_key
57
+ raise "Not a gather" unless gather?
58
+ return @xml["finishOnKey"] || '#' # '#' is the default finish key if not specified
59
+ end
60
+
61
+ def press(digits)
62
+ raise "Not a gather" unless gather?
63
+
64
+ # Fetch the path and then post
65
+ path = gather_action
66
+
67
+ # Update the root call
68
+ root_call.request_for_twiml!(path, :digits => digits, :method => gather_method, :finish_on_key => gather_finish_on_key)
69
+ end
70
+
71
+ # Make this easier to support TwiML elements...
72
+ def method_missing(meth, *args, &block)
73
+ # support any check for a given attribute on a given element
74
+ #
75
+ # eg. has_action_on_dial?, has_method_on_sip?, etc.
76
+ #
77
+ # Attribute-checking appears to be case-sensitive, which x means:
78
+ #
79
+ # has_finishOnKey_on_record?("#")
80
+ #
81
+ # I'm not crazy about this mixed case, so we can also do a more
82
+ # Rubyish way:
83
+ #
84
+ # has_finish_on_key_on_record?("#")
85
+ #
86
+ if meth.to_s =~ /^ha(s|ve)_([a-zA-Z_]+)_on_([a-zA-Z]+)\?$/
87
+ has_attr_on_element?($3, $2, *args, &block)
88
+
89
+ # support any check for the existence of a given element
90
+ # with an optional check on the inner_text.
91
+ elsif meth.to_s =~ /^ha(s|ve)_([a-zA-Z]+)\?$/
92
+ has_element?($2, *args, &block)
93
+
94
+ # get a given element node
95
+ elsif meth.to_s =~ /^get_([a-z]+)_node$/
96
+ get_element_node($1, *args, &block)
97
+
98
+ # run a block within a given node context
99
+ elsif meth.to_s =~ /^within_([a-z]+)$/
100
+ within_element($1, *args, &block)
101
+
102
+ else
103
+ super # You *must* call super if you don't handle the
104
+ # method, otherwise you'll mess up Ruby's method
105
+ # lookup.
106
+ end
107
+ end
108
+
109
+ def respond_to_missing?(method_name, include_private = false)
110
+ method_name.to_s.match(/^(has_|have_|get_[a-z]+_node|within_)/) || super
111
+ end
112
+
113
+ # Some basic accessors
114
+ def current_path
115
+ @current_path
116
+ end
117
+
118
+ def response_xml
119
+ @response_xml
120
+ end
121
+
122
+ def root_call
123
+ @root_call
124
+ end
125
+
126
+ private
127
+ def formatted_digits(digits, options = {})
128
+ if digits.nil?
129
+ ''
130
+ elsif options[:finish_on_key]
131
+ digits.to_s.split(options[:finish_on_key])[0]
132
+ else
133
+ digits
134
+ end
135
+ end
136
+
137
+ def get_element_node(el)
138
+ el[0] = el[0,1].upcase
139
+ @xml.at_xpath(el)
140
+ end
141
+
142
+ # Within element returns a scope that's tied to the specified element
143
+ def within_element(el, &block)
144
+ element_node = get_element_node(el)
145
+
146
+ raise "No el in scope" if element_node.nil?
147
+ yield(CallScope.from_xml(self, element_node))
148
+ end
149
+
150
+ def has_attr_on_element?(el, attr, value)
151
+ el[0] = el[0,1].upcase
152
+ # convert snake case to lower camelCase
153
+ if attr.match(/_/)
154
+ attr = camel_case_lower(attr)
155
+ end
156
+
157
+ attr_on_el = @xml.xpath(el).attribute(attr)
158
+ !!attr_on_el && attr_on_el.value == value
159
+ end
160
+
161
+ def has_element?(el, inner = nil, options = {})
162
+ el[0] = el[0,1].upcase
163
+ return !(@xml.at_xpath(el).nil?) if inner.nil?
164
+
165
+ @xml.xpath(el).each do |s|
166
+ if !options[:exact_inner_match].nil? && options[:exact_inner_match] == true
167
+ return true if s.inner_text.strip == inner
168
+ else
169
+ return true if s.inner_text.include?(inner)
170
+ end
171
+ end
172
+
173
+ return false
174
+ end
175
+
176
+ def camel_case_lower(subject)
177
+ subject.split('_').inject([]){ |buffer,e| buffer.push(buffer.empty? ? e : e.capitalize) }.join
178
+ end
179
+
180
+ protected
181
+ # New object creation
182
+ def self.from_xml(parent, xml)
183
+ new_scope = CallScope.new
184
+ new_scope.send(:set_xml, xml)
185
+ new_scope.send(:root_call=, parent.root_call)
186
+ return new_scope
187
+ end
188
+
189
+ def set_xml(xml)
190
+ @xml = xml
191
+ end
192
+
193
+ # Create a new object from a post. Options:
194
+ # * :method - the http method of the request, defaults to :post
195
+ # * :digits - becomes params[:Digits], defaults to ""
196
+ def self.from_request(parent, path, options = {})
197
+ new_scope = CallScope.new
198
+ new_scope.send(:root_call=, parent.root_call)
199
+ new_scope.send(:request_for_twiml!, path, options)
200
+ return new_scope
201
+ end
202
+
203
+ def normalize_redirect_path(path)
204
+ p = path
205
+
206
+ # Strip off ".xml" off of the end of any path
207
+ p = path[0...path.length - ".xml".length] if path.downcase.match(/\.xml$/)
208
+ return p
209
+ end
210
+
211
+ # Post and update the scope. Options:
212
+ # :digits - becomes params[:Digits], optional (becomes "")
213
+ # :is_machine - becomes params[:AnsweredBy], defaults to false / human
214
+ # :called - becomes params[:Called], like when the REST API is used to initiate a call
215
+ # :direction - becomes params[:Direction]. Should be inbound, outbound-api, or outbound-dial
216
+ def request_for_twiml!(path, options = {})
217
+ @current_path = normalize_redirect_path(path)
218
+
219
+ # Post the query
220
+ rack_test_session_wrapper = Capybara.current_session.driver
221
+ @response = rack_test_session_wrapper.send(options[:method].downcase || :post, @current_path,
222
+ :format => :xml,
223
+ :CallSid => @root_call.sid,
224
+ :From => @root_call.from_number,
225
+ :Digits => formatted_digits(options[:digits].to_s, :finish_on_key => options[:finish_on_key]),
226
+ :To => @root_call.to_number,
227
+ :AnsweredBy => (options[:is_machine] ? "machine" : "human"),
228
+ :CallStatus => options.fetch(:call_status, "in-progress"),
229
+ :Called => options.fetch(:called, ""),
230
+ :Direction => options[:direction].nil? ? "inbound" : options[:direction]
231
+ )
232
+
233
+ # All Twilio responses must be a success.
234
+ raise "Bad response: #{@response.status}" unless @response.status == 200
235
+
236
+ # Load the xml
237
+ data = @response.body
238
+ @response_xml = Nokogiri::XML.parse(data)
239
+ set_xml(@response_xml.at_xpath("Response"))
240
+ end
241
+
242
+ # Parent call control
243
+ def root_call=(val)
244
+ @root_call = val
245
+ end
246
+ end
247
+ end