passbookpgh 0.4.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.travis.yml +7 -0
  3. data/Gemfile +17 -0
  4. data/Gemfile.lock +130 -0
  5. data/LICENSE +22 -0
  6. data/README.md +294 -0
  7. data/Rakefile +36 -0
  8. data/VERSION +1 -0
  9. data/bin/pk +22 -0
  10. data/lib/commands/build.rb +62 -0
  11. data/lib/commands/commands.rb +31 -0
  12. data/lib/commands/generate.rb +44 -0
  13. data/lib/commands/templates/boarding-pass.json +56 -0
  14. data/lib/commands/templates/coupon.json +33 -0
  15. data/lib/commands/templates/event-ticket.json +33 -0
  16. data/lib/commands/templates/generic.json +33 -0
  17. data/lib/commands/templates/store-card.json +33 -0
  18. data/lib/passbook/pkpass.rb +121 -0
  19. data/lib/passbook/push_notification.rb +19 -0
  20. data/lib/passbook/signer.rb +40 -0
  21. data/lib/passbook/version.rb +3 -0
  22. data/lib/passbook.rb +15 -0
  23. data/lib/rack/passbook_rack.rb +98 -0
  24. data/lib/rails/generators/passbook/config/config_generator.rb +16 -0
  25. data/lib/rails/generators/passbook/config/templates/initializer.rb +13 -0
  26. data/lib/utils/command_utils.rb +12 -0
  27. data/passbookpgh.gemspec +110 -0
  28. data/spec/data/icon.png +0 -0
  29. data/spec/data/icon@2x.png +0 -0
  30. data/spec/data/logo.png +0 -0
  31. data/spec/data/logo@2x.png +0 -0
  32. data/spec/lib/commands/build_spec.rb +92 -0
  33. data/spec/lib/commands/commands_spec.rb +102 -0
  34. data/spec/lib/commands/commands_spec_helper.rb +69 -0
  35. data/spec/lib/commands/generate_spec.rb +72 -0
  36. data/spec/lib/passbook/pkpass_spec.rb +108 -0
  37. data/spec/lib/passbook/push_notification_spec.rb +23 -0
  38. data/spec/lib/passbook/signer_spec.rb +84 -0
  39. data/spec/lib/rack/passbook_rack_spec.rb +233 -0
  40. data/spec/spec_helper.rb +9 -0
  41. metadata +244 -0
@@ -0,0 +1,92 @@
1
+ require 'lib/commands/commands_spec_helper'
2
+ require 'passbook'
3
+
4
+ describe 'Build' do
5
+
6
+ before :each do
7
+ $stderr = StringIO.new
8
+ mock_terminal
9
+ end
10
+
11
+ context 'command' do
12
+ specify 'missing directory' do
13
+ run_command 'build' do
14
+ @output.string.should eq "\e[31mMissing argument\e[0m\n"
15
+ end
16
+ end
17
+
18
+ context 'good directory' do
19
+
20
+ before :each do
21
+ File.should_receive(:directory?).with('scraps').and_return true
22
+ File.should_receive(:exist?).once.with('scraps/pass.json').and_return true
23
+ File.should_receive(:exist?).once.with('scraps.pkpass').and_return false
24
+ end
25
+
26
+ specify 'no certificate file' do
27
+ run_command 'build', 'scraps' do
28
+ @output.string.should eq "\e[31mMissing or invalid certificate file\e[0m\n"
29
+ end
30
+ end
31
+
32
+ context 'good certificate file' do
33
+ before :each do
34
+ File.should_receive(:exist?).with('jackels').and_return true
35
+ end
36
+
37
+ specify 'no certificate password entered' do
38
+ run_command 'build', 'scraps', '-w', 'jackels' do
39
+ @output.string.should eq "Enter certificate password:\n\n"
40
+ end
41
+ end
42
+
43
+ context 'all required values' do
44
+
45
+ let(:pass_json) {'{"this":"is awesome json"}'}
46
+ let(:pass_assets) {['pass.json', 'something.jpeg']}
47
+
48
+ before :each do
49
+ Passbook.should_receive(:wwdc_cert=).with 'jackels'
50
+ Passbook.should_receive(:p12_key=).with 'badger_key'
51
+ Passbook.should_receive(:p12_certificate=).with 'badger_cert'
52
+ Passbook.should_receive(:p12_password=).with 'bees'
53
+ CommandUtils.should_receive(:get_assets).with('scraps').and_return pass_assets
54
+ @pk_pass = double 'pk pass'
55
+ File.should_receive(:read).with('pass.json').and_return pass_json
56
+ Passbook::PKPass.should_receive(:new).with(pass_json).and_return @pk_pass
57
+ @pk_pass.should_receive(:addFiles).with pass_assets
58
+ end
59
+
60
+ specify 'are set from command line' do
61
+ pass_stream = double 'passbook stream'
62
+ pass_stream.should_receive(:string).and_return 'my badass pass'
63
+ @pk_pass.should_receive(:stream).and_return pass_stream
64
+ File.should_receive(:open).with('scraps.pkpass', 'w')
65
+
66
+ run_command 'build', 'scraps', '-w', 'jackels',
67
+ '-p', 'bees', '-k', 'badger_key', '-c', 'badger_cert' do
68
+ @output.string.should eq ""
69
+ end
70
+ end
71
+
72
+ specify 'should catch a general error' do
73
+ @pk_pass.should_receive(:stream).and_raise(StandardError.new('I have failed'))
74
+ run_command 'build', 'scraps', '-w', 'jackels',
75
+ '-p', 'bees', '-k', 'badger_key', '-c', 'badger_cert' do
76
+ @output.string.should eq "\e[31mError: I have failed\e[0m\n"
77
+ end
78
+ end
79
+
80
+ specify 'should catch a general error' do
81
+ @pk_pass.should_receive(:stream).and_raise(OpenSSL::PKCS12::PKCS12Error.new('I am a failure'))
82
+ run_command 'build', 'scraps', '-w', 'jackels',
83
+ '-p', 'bees', '-k', 'badger_key', '-c', 'badger_cert' do
84
+ @output.string.should eq "\e[31mError: I am a failure\e[0m\n\e[33mYou may be getting this error because the certificate password is either incorrect or missing\e[0m\n"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ end
@@ -0,0 +1,102 @@
1
+ require 'lib/commands/commands_spec_helper'
2
+
3
+ describe 'Commands' do
4
+
5
+ before :each do
6
+ program :version, '1.2.3'
7
+ program :description, "Honey Badger Don't Care"
8
+ end
9
+
10
+ context 'determine directory' do
11
+
12
+ specify 'no directories' do
13
+ Dir.should_receive(:[]).and_return []
14
+ determine_directory!
15
+ @directory.should eq nil
16
+ end
17
+
18
+ specify 'one directory present' do
19
+ Dir.should_receive(:[]).and_return ['ass']
20
+ File.should_receive(:dirname).with('ass').and_return('/honey/badger/is/bad/ass')
21
+ determine_directory!
22
+ @directory.should eq '/honey/badger/is/bad/ass'
23
+ end
24
+
25
+ specify 'multiple directories' do
26
+ Dir.should_receive(:[]).and_return ['cobras', 'bee_larvae']
27
+ File.should_receive(:dirname).with('cobras').and_return('/yummy/cobras')
28
+ File.should_receive(:dirname).with('bee_larvae').and_return('/disgusting/bee_larvae')
29
+ self.should_receive(:choose).with('Select a directory:',
30
+ '/yummy/cobras', '/disgusting/bee_larvae').and_return '/yummy/cobras'
31
+ determine_directory!
32
+ @directory.should eq '/yummy/cobras'
33
+ end
34
+
35
+ end
36
+
37
+ context 'validate directory' do
38
+
39
+ specify 'missing directory' do
40
+ self.should_receive(:say_error).with('Missing argument').and_return true
41
+ lambda {
42
+ @directory = nil
43
+ validate_directory!
44
+ }.should exit_with_code(1)
45
+ end
46
+
47
+ specify 'directory does not exist' do
48
+ self.should_receive(:say_error).with("Directory scraps does not exist").and_return true
49
+ File.should_receive(:directory?).with('scraps').and_return false
50
+ lambda {
51
+ @directory = 'scraps'
52
+ validate_directory!
53
+ }.should exit_with_code(1)
54
+ end
55
+
56
+ specify 'directory does not have a valid pass' do
57
+ self.should_receive(:say_error).with("Directory scraps is not a valid pass").and_return true
58
+ File.should_receive(:directory?).with('scraps').and_return true
59
+ File.should_receive(:exist?).with('scraps/pass.json').and_return false
60
+ lambda {
61
+ @directory = 'scraps'
62
+ validate_directory!
63
+ }.should exit_with_code(1)
64
+ end
65
+
66
+ specify 'directory has valid pass' do
67
+ File.should_receive(:directory?).with('scraps').and_return true
68
+ File.should_receive(:exist?).with('scraps/pass.json').and_return true
69
+ lambda {
70
+ @directory = 'scraps'
71
+ validate_directory!
72
+ }.should_not exit_with_code(1)
73
+ end
74
+ end
75
+
76
+ context 'validate certificate' do
77
+ specify 'nil certificate' do
78
+ self.should_receive(:say_error).with("Missing or invalid certificate file").and_return true
79
+ lambda {
80
+ @certificate = nil
81
+ validate_certificate!
82
+ }.should exit_with_code(1)
83
+ end
84
+
85
+ specify 'certificate file does not exist' do
86
+ self.should_receive(:say_error).with("Missing or invalid certificate file").and_return true
87
+ File.should_receive(:exist?).with('jackels').and_return false
88
+ lambda {
89
+ @certificate = 'jackels'
90
+ validate_certificate!
91
+ }.should exit_with_code(1)
92
+ end
93
+
94
+ specify 'certificate file exists' do
95
+ File.should_receive(:exist?).with('jackels').and_return true
96
+ lambda {
97
+ @certificate = 'jackels'
98
+ validate_certificate!
99
+ }.should_not exit_with_code(1)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,69 @@
1
+ require 'spec_helper'
2
+ require 'terminal-table'
3
+ require 'commander/import'
4
+ require 'utils/command_utils'
5
+
6
+ def load_commands
7
+ Dir['lib/commands/**/*.rb'].each {|f|
8
+ load File.join(File.dirname(__FILE__), '../../..', f)
9
+ require File.join(File.dirname(__FILE__), '../../..', f.gsub(/.rb/, ''))
10
+ }
11
+ end
12
+
13
+ def mock_terminal
14
+ @input = StringIO.new
15
+ @output = StringIO.new
16
+ $terminal = HighLine.new @input, @output
17
+ end
18
+
19
+ def new_command_runner *args, &block
20
+ Commander::Runner.instance_variable_set :"@singleton", Commander::Runner.new(args)
21
+ program :version, '1.2.3'
22
+ program :description, "Honey Badger Don't Care"
23
+ yield if block
24
+ Commander::Runner.instance
25
+ end
26
+
27
+ def run *args
28
+ runner = new_command_runner(*args) do
29
+ load_commands
30
+ end
31
+ runner.run!
32
+ @output.string
33
+ end
34
+
35
+ RSpec::Matchers.define :exit_with_code do |exp_code|
36
+ actual = nil
37
+ match do |block|
38
+ begin
39
+ block.call
40
+ rescue SystemExit => e
41
+ actual = e.status
42
+ end
43
+ actual and actual == exp_code
44
+ end
45
+ failure_message_for_should do |block|
46
+ "expected block to call exit(#{exp_code}) but exit" +
47
+ (actual.nil? ? " not called" : "(#{actual}) was called")
48
+ end
49
+ failure_message_for_should_not do |block|
50
+ "expected block not to call exit(#{exp_code})"
51
+ end
52
+ description do
53
+ "expect block to call exit(#{exp_code})"
54
+ end
55
+ end
56
+
57
+ def run_raw_command(*args)
58
+ lambda{
59
+ run(*args)
60
+ }.should raise_error(SystemExit, ' ')
61
+ end
62
+
63
+ def run_command(*args, &block)
64
+ begin
65
+ run_raw_command *args
66
+ rescue
67
+ yield
68
+ end
69
+ end
@@ -0,0 +1,72 @@
1
+ require 'lib/commands/commands_spec_helper'
2
+ require 'passbook'
3
+
4
+ describe 'Generate' do
5
+
6
+ before :each do
7
+ $stderr = StringIO.new
8
+ mock_terminal
9
+ end
10
+
11
+ context 'command' do
12
+ specify 'no options' do
13
+ run_command 'generate' do
14
+ @output.string.should eq 'Enter a passbook name: '
15
+ end
16
+
17
+ end
18
+
19
+ specify 'passbook entered directory already exists' do
20
+ @input << "my_awesome_passbook\n"
21
+ @input.rewind
22
+ File.should_receive(:directory?).with('my_awesome_passbook').and_return true
23
+ run_command 'generate' do
24
+ @output.string.should eq "Enter a passbook name: \e[31mDirectory my_awesome_passbook already exists\e[0m\n"
25
+ end
26
+ end
27
+
28
+ specify 'passbook entered file already exists' do
29
+ @input << "my_awesome_passbook\n"
30
+ @input.rewind
31
+ File.should_receive(:directory?).with('my_awesome_passbook').and_return false
32
+ File.should_receive(:exist?).with('my_awesome_passbook').and_return true
33
+ run_command 'generate' do
34
+ @output.string.should eq "Enter a passbook name: \e[31mFile exists at my_awesome_passbook\e[0m\n"
35
+ end
36
+ end
37
+
38
+ context 'valid pass directory' do
39
+
40
+ before :each do
41
+ File.should_receive(:directory?).with('my_awesome_passbook').and_return false
42
+ File.should_receive(:exist?).with('my_awesome_passbook').and_return false
43
+ end
44
+
45
+ specify 'invalid type' do
46
+ @input << "my_awesome_passbook\n"
47
+ @input.rewind
48
+ run_command 'generate', '-T', 'honey_badger' do
49
+ @output.string.should eq "Enter a passbook name: \e[31mInvalid type: \"honey_badger\", expected one of: [boarding-pass, coupon, event-ticket, store-card, generic]\e[0m\n"
50
+ end
51
+ end
52
+
53
+ specify 'valid type' do
54
+ CommandUtils.should_receive(:get_current_directory).and_return('')
55
+ FileUtils.should_receive(:mkdir_p).with('my_awesome_passbook')
56
+ FileUtils.should_receive(:cp).with("/../commands/templates/boarding-pass.json", "my_awesome_passbook/pass.json")
57
+ FileUtils.should_receive(:touch).with("my_awesome_passbook/icon.png")
58
+ FileUtils.should_receive(:touch).with("my_awesome_passbook/icon@2x.png")
59
+ @input << "1\n"
60
+ @input.rewind
61
+ run_command 'generate', 'my_awesome_passbook' do
62
+ @output.string.should eq "Select a pass type\n1. boarding-pass\n2. coupon\n3. event-ticket\n4. store-card\n5. generic\n? \e[32mPass generated in my_awesome_passbook\e[0m\n"
63
+ end
64
+ end
65
+ end
66
+
67
+
68
+ end
69
+
70
+
71
+ end
72
+
@@ -0,0 +1,108 @@
1
+ require 'spec_helper'
2
+
3
+ describe Passbook do
4
+
5
+ let (:content) {{
6
+ :formatVersion => 1,
7
+ :passTypeIdentifier => "pass.passbook.test",
8
+ :serialNumber => "001",
9
+ :teamIdentifier => ENV['APPLE_TEAM_ID'],
10
+ :relevantDate => "2012-10-02",
11
+ :locations => [ #TODO
12
+ {
13
+ :longitude => 2.35403,
14
+ :latitude => 48.893855
15
+ }
16
+ ],
17
+ :organizationName => "WorldCo",
18
+ :description => "description",
19
+ :foregroundColor => "rgb(227,210,18)",
20
+ :backgroundColor => "rgb(60, 65, 76)",
21
+ :logoText => "Event",
22
+ :eventTicket => {
23
+ :primaryFields => [
24
+ {
25
+ :key => "date",
26
+ :label => "DATE",
27
+ :value => "date"
28
+ }
29
+ ],
30
+ :backFields => [
31
+ {
32
+ :key => "description",
33
+ :label => "DESCRIPTION",
34
+ :value => "description"
35
+ },
36
+ {
37
+ :key => "aboutUs",
38
+ :label => "MORE",
39
+ :value => "about us"
40
+ }
41
+ ]
42
+ }
43
+ }}
44
+
45
+ let (:signer) {double 'signer'}
46
+ let (:pass) {Passbook::PKPass.new content.to_json, signer}
47
+
48
+ context 'outputs' do
49
+ let (:base_path) {'spec/data'}
50
+ let (:entries) {["pass.json", "manifest.json", "signature", "icon.png", "icon@2x.png", "logo.png", "logo@2x.png"]}
51
+
52
+ before :each do
53
+ pass.addFiles ["#{base_path}/icon.png","#{base_path}/icon@2x.png","#{base_path}/logo.png","#{base_path}/logo@2x.png"]
54
+ signer.should_receive(:sign).and_return('Signed by the Honey Badger')
55
+ @file_entries = []
56
+ Zip::InputStream::open(zip_path) {|io|
57
+ while (entry = io.get_next_entry)
58
+ @file_entries << entry.name
59
+ end
60
+ }
61
+ end
62
+
63
+ context 'zip file' do
64
+ let(:zip_path) {pass.file.path}
65
+
66
+ subject {entries}
67
+ it {should eq @file_entries}
68
+ end
69
+
70
+ context 'StringIO' do
71
+ let (:temp_file) {Tempfile.new("pass.pkpass")}
72
+ let (:zip_path) {
73
+ zip_out = pass.stream
74
+ zip_out.class.should eq(Class::StringIO)
75
+ #creating file, re-reading zip to see if correctly formed
76
+ temp_file.write zip_out.string
77
+ temp_file.close
78
+ temp_file.path
79
+ }
80
+
81
+ subject {entries}
82
+ it {should eq @file_entries}
83
+
84
+ after do
85
+ temp_file.delete
86
+ end
87
+ end
88
+ end
89
+
90
+ # TODO: find a proper way to do this
91
+ context 'Error catcher' do
92
+ context 'formatVersion' do
93
+ let (:base_path) {'spec/data'}
94
+
95
+ before :each do
96
+ pass.addFiles ["#{base_path}/icon.png","#{base_path}/icon@2x.png","#{base_path}/logo.png","#{base_path}/logo@2x.png"]
97
+ tpass = JSON.parse(pass.pass)
98
+ tpass['formatVersion'] = 'It should be a numeric'
99
+ pass.pass = tpass.to_json
100
+ end
101
+
102
+ it "raise an error" do
103
+ expect { pass.build }.to raise_error('Format Version should be a numeric')
104
+ end
105
+
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,23 @@
1
+ require 'spec_helper'
2
+ require 'grocer'
3
+
4
+ describe Passbook::PushNotification do
5
+
6
+ context 'send notification' do
7
+ let(:grocer_pusher) {double 'Grocer'}
8
+ let(:notification) {double 'Grocer::Notification'}
9
+ let(:notification_settings) {{:certificate => './notification_cert.pem', :gateway => 'honeybadger.apple.com', :passphrase => 'ah@rdvintAge'}}
10
+
11
+ before :each do
12
+ Passbook.should_receive(:notification_cert).and_return './notification_cert.pem'
13
+ Grocer::PassbookNotification.should_receive(:new).with(:device_token => 'my token').and_return notification
14
+ grocer_pusher.should_receive(:push).with(notification).and_return 55
15
+ Grocer.should_receive(:pusher).with(notification_settings).and_return grocer_pusher
16
+ Passbook.should_receive(:notification_gateway).and_return 'honeybadger.apple.com'
17
+ Passbook.should_receive(:notification_passphrase).and_return 'ah@rdvintAge'
18
+ end
19
+
20
+ subject {Passbook::PushNotification.send_notification('my token')}
21
+ it {should eq 55}
22
+ end
23
+ end
@@ -0,0 +1,84 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Signer' do
4
+ context 'signatures' do
5
+
6
+ context 'p12_cert_and_key' do
7
+ context 'pem p12 certs' do
8
+ context 'using config file certificates' do
9
+ before do
10
+ Passbook.should_receive(:p12_password).and_return 'password'
11
+ Passbook.should_receive(:p12_key).and_return 'my_p12_key'
12
+ Passbook.should_receive(:p12_certificate).and_return 'my_p12_certificate'
13
+ Passbook.should_receive(:wwdc_cert).and_return 'i_love_robots'
14
+ File.should_receive(:read).with('my_p12_key').and_return 'my_p12_key_file'
15
+ File.should_receive(:read).with('my_p12_certificate').and_return 'my_p12_certificate_file'
16
+ OpenSSL::PKey::RSA.should_receive(:new).with('my_p12_key_file', 'password').and_return 'my_rsa_key'
17
+ OpenSSL::X509::Certificate.should_receive(:new).with('my_p12_certificate_file').and_return 'my_ssl_p12_cert'
18
+ end
19
+
20
+ subject {Passbook::Signer.new.key_hash}
21
+ its([:key]) {should eq 'my_rsa_key'}
22
+ its([:cert]) {should eq 'my_ssl_p12_cert'}
23
+ end
24
+
25
+ context 'using passed in certificates' do
26
+ before do
27
+ Passbook.should_receive(:p12_password).never
28
+ Passbook.should_receive(:p12_key).never
29
+ Passbook.should_receive(:p12_certificate).never
30
+ Passbook.should_receive(:wwdc_cert).never
31
+ File.should_receive(:read).with('my_p12_key').and_return 'my_p12_key_file'
32
+ File.should_receive(:read).with('my_p12_certificate').and_return 'my_p12_certificate_file'
33
+ OpenSSL::PKey::RSA.should_receive(:new).with('my_p12_key_file', 'password').and_return 'my_rsa_key'
34
+ OpenSSL::X509::Certificate.should_receive(:new).with('my_p12_certificate_file').and_return 'my_ssl_p12_cert'
35
+ end
36
+
37
+ subject {Passbook::Signer.new(certificate: 'my_p12_certificate', password: 'password',
38
+ key: 'my_p12_key', wwdc_cert: 'i_love_robots').key_hash}
39
+ its([:key]) {should eq 'my_rsa_key'}
40
+ its([:cert]) {should eq 'my_ssl_p12_cert'}
41
+ end
42
+ end
43
+
44
+ context 'p12 files' do
45
+ let (:p12) { double('OpenSSL::PKCS12') }
46
+ let (:final_hash) {{:key => 'my_final_p12_key', :cert => 'my_final_p12_cert'}}
47
+ context 'using config file certificates' do
48
+ before do
49
+ p12.should_receive(:key).and_return final_hash[:key]
50
+ p12.should_receive(:certificate).and_return final_hash[:cert]
51
+ Passbook.should_receive(:p12_password).and_return 'password'
52
+ Passbook.should_receive(:wwdc_cert).and_return 'i_love_robots'
53
+ Passbook.should_receive(:p12_certificate).and_return 'my_p12_cert'
54
+ Passbook.should_receive(:p12_key).and_return nil
55
+ File.should_receive(:read).with('my_p12_cert').and_return 'my_p12_cert_file'
56
+ OpenSSL::PKCS12.should_receive(:new).with('my_p12_cert_file', 'password').and_return p12
57
+ end
58
+
59
+ subject {Passbook::Signer.new.key_hash}
60
+ its([:key]) {should eq final_hash[:key]}
61
+ its([:cert]) {should eq final_hash[:cert]}
62
+ end
63
+
64
+ context 'using passed in certificates' do
65
+ before do
66
+ p12.should_receive(:key).and_return final_hash[:key]
67
+ p12.should_receive(:certificate).and_return final_hash[:cert]
68
+ Passbook.should_receive(:p12_password).never
69
+ Passbook.should_receive(:p12_key).never
70
+ Passbook.should_receive(:p12_certificate).never
71
+ Passbook.should_receive(:wwdc_cert).never
72
+ File.should_receive(:read).with('my_p12_cert').and_return 'my_p12_cert_file'
73
+ OpenSSL::PKCS12.should_receive(:new).with('my_p12_cert_file', 'password').and_return p12
74
+ end
75
+
76
+ subject {Passbook::Signer.new(certificate: 'my_p12_cert', password: 'password',
77
+ wwdc_cert: 'i_love_robots').key_hash}
78
+ its([:key]) {should eq final_hash[:key]}
79
+ its([:cert]) {should eq final_hash[:cert]}
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end