tincan 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 2967dd0d4f1f05af20103c1c50939111c751aa90
4
+ data.tar.gz: 17ff307ad20ea27021eb624c5d3a9ece804e8d89
5
+ SHA512:
6
+ metadata.gz: 92c22a93fc950507fa525d98b6711ae1237d558082aac60eb7e5ec1b85026d189a48bd71531a1a12045e5d3127146269d638ae54832ab1be19329fa3d9a35b54
7
+ data.tar.gz: c4a48557ced89f840dc503284ea6d4e20413be60b7e4040eca5aa81f9e7666a480ad1eac7d8d9c12e3225ded13d045ee434863711c393ae5cadf6cef453378ec
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ .ruby-version
data/Gemfile ADDED
@@ -0,0 +1,19 @@
1
+ source 'https://rubygems.org'
2
+
3
+ ruby '2.0.0'
4
+
5
+ gemspec
6
+
7
+ gem 'rake', require: false
8
+ gem 'oj', require: false
9
+
10
+ group :development do
11
+ gem 'shotgun', require: false
12
+ end
13
+
14
+ group :test do
15
+ gem 'minitest'
16
+ gem 'rack-test'
17
+ gem 'fakeredis'
18
+ gem 'simplecov', require: false
19
+ end
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:spec) do |t|
5
+ t.libs << 'spec'
6
+ t.pattern = 'spec/**/*_spec.rb'
7
+ end
8
+ task :default => :spec
9
+
10
+ task :coverage do
11
+ `open coverage/index.html`
12
+ end
data/Readme.markdown ADDED
@@ -0,0 +1,78 @@
1
+ # Phone
2
+
3
+ Phone number provider.
4
+
5
+ # Configuration
6
+
7
+ Tincan delegates out the sending of SMS messages. Place the following code in an initializer
8
+ with an implementation in order to use it.
9
+
10
+ ```ruby
11
+ Tincan::SMS.sender = lambda do |phone_number, body|
12
+ puts "SMS sent to #{phone_number} - #{body}"
13
+ end
14
+ ```
15
+
16
+ ## Create
17
+
18
+ First, create a phone number verification:
19
+
20
+ POST /v1/phone_numbers
21
+ phone_number=%2B14152751660&message_format=Click%20this%20link%3A%20https%3A//seesaw.co/a/CODE
22
+
23
+ Response:
24
+
25
+ ``` json
26
+ {
27
+ "id": "0H2r2U1W3s3I0M3K0k173j1s3y2i2r05",
28
+ "e164": "+14152751660",
29
+ "country_code": "US",
30
+ "local_format": "(415) 275-1660",
31
+ "verified_at": null
32
+ }
33
+ ```
34
+
35
+ This will send a text message with the following message:
36
+
37
+ > Click this link: https://seesaw.co/a/aFdh79mD"
38
+
39
+
40
+ ## Verify
41
+
42
+ A client can now make the following request:
43
+
44
+ POST /v1/phone_numbers/verify
45
+ code=aFdh79mD
46
+
47
+ Response:
48
+
49
+ ``` json
50
+ {
51
+ "id": "0H2r2U1W3s3I0M3K0k173j1s3y2i2r05",
52
+ "e164": "+14152751660",
53
+ "country_code": "US",
54
+ "local_format": "(415) 275-1660",
55
+ "verified_at": 1365445347
56
+ }
57
+ ```
58
+
59
+ Now `verified_at` is set and the client knows this is a valid phone number.
60
+
61
+
62
+ ## Show
63
+
64
+ You can show a phone number token for a day to poll to see if it's verified. This will probably rarely be used since the request to verify it will be the only place this information is needed.
65
+
66
+ GET /v1/phone_numbers/0H2r2U1W3s3I0M3K0k173j1s3y2i2r05
67
+
68
+ Response:
69
+
70
+ ``` json
71
+ {
72
+ "id": "0H2r2U1W3s3I0M3K0k173j1s3y2i2r05",
73
+ "e164": "+14152751660",
74
+ "country_code": "US",
75
+ "local_format": "(415) 275-1660",
76
+ "verified_at": 1365445347
77
+ }
78
+ ```
data/config.ru ADDED
@@ -0,0 +1,4 @@
1
+ $LOAD_PATH.unshift 'lib'
2
+ require 'tincan'
3
+
4
+ run Tincan::Application
@@ -0,0 +1,87 @@
1
+ require 'sinatra/base'
2
+ require 'fakie/errors'
3
+
4
+ module Tincan
5
+ class Application < Sinatra::Base
6
+ # Create phone number
7
+ post '/' do
8
+ unless phone_number = params['phone_number']
9
+ status 400
10
+ return json({
11
+ error: 'bad_request',
12
+ error_description: "The `phone_number` parameter is required."
13
+ })
14
+ end
15
+
16
+ unless message_format = params['message_format']
17
+ status 400
18
+ return json({
19
+ error: 'bad_request',
20
+ error_description: "The `message_format` parameter is required."
21
+ })
22
+ end
23
+
24
+ begin
25
+ phone = PhoneNumber.create!(phone_number)
26
+ rescue Fakie::InvalidPhoneNumber
27
+ status 400
28
+ hash = {
29
+ error: 'invalid_phone_number',
30
+ error_description: 'The phone number provided was invalid.'
31
+ }
32
+ return json(hash)
33
+ end
34
+
35
+ SMS.send(phone, message_format)
36
+
37
+ status 201
38
+ json phone.as_json
39
+ end
40
+
41
+ # Verify phone number
42
+ post '/verify' do
43
+ unless code = params['code']
44
+ status 400
45
+ return json({
46
+ error: 'bad_request',
47
+ error_description: "The `code` parameter is required."
48
+ })
49
+ end
50
+
51
+ if phone = PhoneNumber.verify_code!(code)
52
+ return json(phone.as_json)
53
+ end
54
+
55
+ status 400
56
+ json({
57
+ error: 'invalid_code',
58
+ error_description: 'There were no phone numbers matching the given code.'
59
+ })
60
+ end
61
+
62
+ # Show phone number
63
+ get '/:id' do
64
+ if phone = PhoneNumber.find(params[:id])
65
+ return json(phone)
66
+ end
67
+
68
+ status 404
69
+ json({
70
+ error: 'not_found',
71
+ error_description: 'A phone_number was not found with this id.'
72
+ })
73
+ end
74
+
75
+ private
76
+
77
+ def json(content)
78
+ content_type 'application/json'
79
+ MultiJson.dump(content)
80
+ end
81
+
82
+ def params
83
+ return super if request.get? || !request.content_type || request.content_type.index('application/json') != 0
84
+ @_params ||= MultiJson.load(request.env['rack.input'].read)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,94 @@
1
+ require 'multi_json'
2
+ require 'fakie'
3
+
4
+ module Tincan
5
+ class PhoneNumber
6
+ REDIS_KEY_PREFIX = 'phone-number_'
7
+ CODE_REDIS_KEY_PREFIX = 'phone-number-code'
8
+ EXPIRATION = 86400 # one day of in-cache storage
9
+
10
+ attr_reader :id, :e164, :country_code, :local_format, :verified_at
11
+ attr_accessor :code
12
+
13
+ class << self
14
+ def create!(e164)
15
+ # TODO: Make sure there aren't collisions
16
+ parsed = Fakie.parse(e164)
17
+ phone = new({
18
+ 'id' => Utils.generate_code(32),
19
+ 'e164' => parsed.e164,
20
+ 'country_code' => parsed.region_code,
21
+ 'local_format' => parsed.local_format
22
+ })
23
+
24
+ phone.save
25
+
26
+ # Store code
27
+ phone.code = Utils.generate_code(8)
28
+ key = "#{CODE_REDIS_KEY_PREFIX}#{phone.code}"
29
+ Tincan.redis.set(key, phone.id)
30
+ Tincan.redis.expire(key, EXPIRATION)
31
+ phone
32
+ end
33
+
34
+ def find(id)
35
+ return nil unless id
36
+
37
+ result = Tincan.redis.get("#{REDIS_KEY_PREFIX}#{id}")
38
+ return nil unless result
39
+
40
+ new(MultiJson.load(result))
41
+ end
42
+
43
+ def verify_code!(code)
44
+ id = Tincan.redis.get("#{CODE_REDIS_KEY_PREFIX}#{code}")
45
+ return nil unless phone = find(id)
46
+
47
+ phone.verify!
48
+ phone
49
+ end
50
+ end
51
+
52
+ def initialize(hash)
53
+ %w{id e164 country_code local_format}.each do |key|
54
+ instance_variable_set(:"@#{key}", hash[key])
55
+ end
56
+
57
+ @verified_at = Time.at(hash['verified_at']) if hash['verified_at']
58
+ end
59
+
60
+ def verified?
61
+ verified_at && verified_at < Time.now.utc
62
+ end
63
+
64
+ def verify!
65
+ @verified_at = Time.now.utc
66
+ save
67
+ end
68
+
69
+ def as_json
70
+ hash = {}
71
+
72
+ # Default attributes
73
+ %w{id e164 country_code local_format}.each do |key|
74
+ hash[key] = instance_variable_get(:"@#{key}")
75
+ end
76
+
77
+ # Add `verified_at` as an integer
78
+ hash['verified_at'] = verified_at.to_i if verified_at
79
+
80
+ # Return the hash
81
+ hash
82
+ end
83
+
84
+ def to_json
85
+ MultiJson.dump(as_json)
86
+ end
87
+
88
+ def save
89
+ key = "#{REDIS_KEY_PREFIX}#{id}"
90
+ Tincan.redis.set(key, to_json)
91
+ Tincan.redis.expire(key, EXPIRATION)
92
+ end
93
+ end
94
+ end
data/lib/tincan/sms.rb ADDED
@@ -0,0 +1,14 @@
1
+ module Tincan
2
+ class SMS
3
+ class << self
4
+ attr_accessor :sender
5
+
6
+ def send(number, message)
7
+ raise NoSmsSenderError.new unless @sender
8
+ @sender.call number.e164, message.sub('CODE', number.code)
9
+ end
10
+ end
11
+
12
+ class NoSmsSenderError < ArgumentError; end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ require 'base62'
2
+
3
+ module Tincan
4
+ module Utils
5
+ module_function
6
+
7
+ def generate_code(length = 8)
8
+ SecureRandom.random_bytes((length / 2.0).ceil).unpack('C*').map do |int|
9
+ int.base62_encode.rjust 2, '0'
10
+ end.join[0...length]
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module Tincan
2
+ VERSION = '0.1.5'
3
+ end
data/lib/tincan.rb ADDED
@@ -0,0 +1,54 @@
1
+ require 'tincan/version'
2
+ require 'tincan/utils'
3
+ require 'tincan/phone_number'
4
+ require 'tincan/application'
5
+ require 'tincan/sms'
6
+
7
+ require 'redis'
8
+ require 'redis/namespace'
9
+
10
+ module Tincan
11
+ def self.configure
12
+ yield self
13
+ end
14
+
15
+ def self.redis
16
+ # Set redis to nothing make the setter run and setup a default if it's nothing
17
+ self.redis = {} unless defined? @@redis
18
+
19
+ # Return the namespaced Redis instance
20
+ @@redis
21
+ end
22
+
23
+ def self.redis=(options = {})
24
+ client = nil
25
+ if options.is_a?(Redis)
26
+ client = options
27
+ else
28
+ url = options[:url] || determine_redis_provider || 'redis://localhost:6379/0'
29
+ driver = options[:driver] || 'ruby'
30
+ namespace = options[:namespace] || 'tincan'
31
+
32
+ client = Redis.connect(url: url, driver: driver)
33
+ end
34
+
35
+ @@redis = Redis::Namespace.new(namespace, redis: client)
36
+ end
37
+
38
+ def self.sms_sender
39
+ SMS.sender
40
+ end
41
+
42
+ def self.sms_sender=(sender)
43
+ SMS.sender = sender
44
+ end
45
+
46
+ private
47
+
48
+ def self.determine_redis_provider
49
+ return ENV['REDISTOGO_URL'] if ENV['REDISTOGO_URL']
50
+ provider = ENV['REDIS_PROVIDER'] || 'REDIS_URL'
51
+ ENV[provider]
52
+ end
53
+
54
+ end
@@ -0,0 +1,47 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Create integration' do
4
+
5
+ before do
6
+ Tincan::SMS.sender = lambda do |number, body|
7
+ @messages = [] unless @messages
8
+ @messages << {number: number, body: body}
9
+ end
10
+ end
11
+
12
+ after do
13
+ Tincan::SMS.sender = nil
14
+ end
15
+
16
+ it 'requires a phone number' do
17
+ post '/'
18
+ last_status.must_equal 400
19
+ last_json['error'].must_equal 'bad_request'
20
+ end
21
+
22
+ it 'requires a valid phone number' do
23
+ post '/', phone_number: '+14152751660', message_format: 'Click this: https://seesaw.co/CODE'
24
+ last_status.must_equal 201
25
+ last_json['e164'].must_equal '+14152751660'
26
+ last_json['local_format'].must_equal '(415) 275-1660'
27
+
28
+ # Verify the last messages are in the correct formats
29
+ @messages.last[:number].must_equal last_json['e164']
30
+ @messages.last[:body].must_match /https\:\/\/seesaw.co\/(\S+)/
31
+ end
32
+
33
+ it 'handles invalid phone numbers' do
34
+ post '/', phone_number: '+1415275166', message_format: 'Click this: https://seesaw.co/CODE'
35
+ last_status.must_equal 400
36
+ last_json['error'].must_equal 'invalid_phone_number'
37
+ end
38
+
39
+ it 'handles missing message formats' do
40
+ post '/', phone_number: '+14152751660'
41
+ last_status.must_equal 400
42
+ last_json['error'].must_equal 'bad_request'
43
+ end
44
+
45
+ it 'sends an SMS'
46
+ it 'store a redirect URI'
47
+ end
@@ -0,0 +1,22 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Verify integration' do
4
+ it 'should require a code' do
5
+ post '/verify'
6
+ last_status.must_equal 400
7
+ last_json['error'].must_equal 'bad_request'
8
+ end
9
+
10
+ it 'should require a valid code' do
11
+ post '/verify', code: 'asdf'
12
+ last_status.must_equal 400
13
+ last_json['error'].must_equal 'invalid_code'
14
+
15
+ phone = Tincan::PhoneNumber.create!('+14152751660')
16
+ post '/verify', code: phone.code
17
+ last_status.must_equal 200
18
+
19
+ get "/#{phone.id}"
20
+ last_json['verified_at'].wont_be_nil
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ Bundler.require :test
4
+
5
+ require 'simplecov'
6
+ SimpleCov.start do
7
+ add_filter '/spec/'
8
+ end
9
+
10
+ require 'minitest/autorun'
11
+ require 'tincan'
12
+
13
+ # Support files
14
+ Dir["#{File.expand_path(File.dirname(__FILE__))}/support/*.rb"].each do |file|
15
+ require file
16
+ end
17
+
18
+ class IntegrationTest < MiniTest::Spec
19
+ include Rack::Test::Methods
20
+ include RequestMacros
21
+
22
+ def app
23
+ Tincan::Application
24
+ end
25
+
26
+ def teardown
27
+ super
28
+ Tincan.redis.flushdb
29
+ end
30
+
31
+ register_spec_type(/integration$/, self)
32
+ end
@@ -0,0 +1,13 @@
1
+ module RequestMacros
2
+ def last_json
3
+ return @_last_json if @_last_json_id == last_response.object_id
4
+ @_last_json_id = last_response.object_id
5
+ @_last_json = last_response.body.length > 0 ? MultiJson.load(last_response.body) : nil
6
+ rescue MultiJson::LoadError
7
+ raise "Failed to parse JSON:\n\n#{last_response.body}"
8
+ end
9
+
10
+ def last_status
11
+ last_response.status
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+
3
+ describe Tincan::SMS do
4
+
5
+ it 'should explode if you try to send without a sender' do
6
+ p = proc { Tincan::SMS.send(Tincan::PhoneNumber.create!('+17174683737'), 'body') }
7
+ p.must_raise Tincan::SMS::NoSmsSenderError
8
+ end
9
+
10
+ end
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+
3
+ describe Tincan::Utils do
4
+ describe 'generate_code' do
5
+ subject do
6
+ Tincan::Utils.generate_code
7
+ end
8
+
9
+ it 'returns a string' do
10
+ subject.must_be_kind_of String
11
+ end
12
+
13
+ it 'defaults to 8 characters' do
14
+ subject.length.must_equal 8
15
+ end
16
+
17
+ it 'supports odd numbers of characters' do
18
+ Tincan::Utils.generate_code(7).length.must_equal 7
19
+ end
20
+ end
21
+ end
data/tincan.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'tincan/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = 'tincan'
8
+ gem.version = Tincan::VERSION
9
+ gem.authors = ['Sam Soffes']
10
+ gem.email = ['sam@soff.es']
11
+ gem.description = 'Phone number provider.'
12
+ gem.summary = gem.description
13
+ gem.homepage = 'https://github.com/seesawco/tincan'
14
+ gem.license = 'MIT'
15
+
16
+ gem.files = `git ls-files`.split($/)
17
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
18
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
+ gem.require_paths = ['lib']
20
+
21
+ gem.required_ruby_version = '>= 1.9.2'
22
+ gem.add_dependency 'sinatra'
23
+ gem.add_dependency 'fakie'
24
+ gem.add_dependency 'redis'
25
+ gem.add_dependency 'redis-namespace'
26
+ gem.add_dependency 'base62'
27
+ gem.add_dependency 'multi_json'
28
+ end
metadata ADDED
@@ -0,0 +1,152 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tincan
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.5
5
+ platform: ruby
6
+ authors:
7
+ - Sam Soffes
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-07-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sinatra
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: fakie
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: redis
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: redis-namespace
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '>='
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: base62
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '>='
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: multi_json
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Phone number provider.
98
+ email:
99
+ - sam@soff.es
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - .gitignore
105
+ - Gemfile
106
+ - Rakefile
107
+ - Readme.markdown
108
+ - config.ru
109
+ - lib/tincan.rb
110
+ - lib/tincan/application.rb
111
+ - lib/tincan/phone_number.rb
112
+ - lib/tincan/sms.rb
113
+ - lib/tincan/utils.rb
114
+ - lib/tincan/version.rb
115
+ - spec/integration/create_spec.rb
116
+ - spec/integration/verify_spec.rb
117
+ - spec/spec_helper.rb
118
+ - spec/support/request_macros.rb
119
+ - spec/unit/sms_spec.rb
120
+ - spec/unit/utils_spec.rb
121
+ - tincan.gemspec
122
+ homepage: https://github.com/seesawco/tincan
123
+ licenses:
124
+ - MIT
125
+ metadata: {}
126
+ post_install_message:
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - '>='
133
+ - !ruby/object:Gem::Version
134
+ version: 1.9.2
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - '>='
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubyforge_project:
142
+ rubygems_version: 2.0.3
143
+ signing_key:
144
+ specification_version: 4
145
+ summary: Phone number provider.
146
+ test_files:
147
+ - spec/integration/create_spec.rb
148
+ - spec/integration/verify_spec.rb
149
+ - spec/spec_helper.rb
150
+ - spec/support/request_macros.rb
151
+ - spec/unit/sms_spec.rb
152
+ - spec/unit/utils_spec.rb