sfdc 3.0.0 → 3.0.1

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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/Gemfile +11 -0
  4. data/Guardfile +11 -0
  5. data/LICENSE +22 -0
  6. data/README.md +88 -98
  7. data/Rakefile +10 -0
  8. data/lib/sfdc.rb +1 -0
  9. data/lib/sfdc/abstract_client.rb +1 -1
  10. data/lib/sfdc/attachment.rb +0 -2
  11. data/lib/sfdc/concerns/api.rb +1 -2
  12. data/lib/sfdc/concerns/authentication.rb +1 -2
  13. data/lib/sfdc/concerns/base.rb +5 -7
  14. data/lib/sfdc/concerns/caching.rb +1 -3
  15. data/lib/sfdc/concerns/canvas.rb +0 -2
  16. data/lib/sfdc/concerns/connection.rb +2 -3
  17. data/lib/sfdc/concerns/picklists.rb +1 -4
  18. data/lib/sfdc/concerns/streaming.rb +2 -2
  19. data/lib/sfdc/concerns/verbs.rb +0 -2
  20. data/lib/sfdc/config.rb +1 -0
  21. data/lib/sfdc/middleware/authentication.rb +4 -2
  22. data/lib/sfdc/middleware/authentication/password.rb +2 -5
  23. data/lib/sfdc/middleware/authorization.rb +1 -5
  24. data/lib/sfdc/middleware/caching.rb +0 -2
  25. data/lib/sfdc/middleware/instance_url.rb +0 -4
  26. data/lib/sfdc/middleware/mashify.rb +1 -3
  27. data/lib/sfdc/middleware/multipart.rb +6 -4
  28. data/lib/sfdc/signed_request.rb +1 -1
  29. data/lib/sfdc/tooling/client.rb +4 -6
  30. data/lib/sfdc/version.rb +2 -2
  31. data/sfdc.gemspec +27 -0
  32. data/spec/fixtures/auth_error_response.json +4 -0
  33. data/spec/fixtures/auth_success_response.json +7 -0
  34. data/spec/fixtures/blob.jpg +0 -0
  35. data/spec/fixtures/expired_session_response.json +6 -0
  36. data/spec/fixtures/reauth_success_response.json +7 -0
  37. data/spec/fixtures/refresh_error_response.json +4 -0
  38. data/spec/fixtures/refresh_success_response.json +7 -0
  39. data/spec/fixtures/services_data_success_response.json +12 -0
  40. data/spec/fixtures/sobject/create_success_response.json +5 -0
  41. data/spec/fixtures/sobject/delete_error_response.json +1 -0
  42. data/spec/fixtures/sobject/describe_sobjects_success_response.json +31 -0
  43. data/spec/fixtures/sobject/list_sobjects_success_response.json +31 -0
  44. data/spec/fixtures/sobject/org_query_response.json +11 -0
  45. data/spec/fixtures/sobject/query_aggregate_success_response.json +23 -0
  46. data/spec/fixtures/sobject/query_empty_response.json +5 -0
  47. data/spec/fixtures/sobject/query_error_response.json +6 -0
  48. data/spec/fixtures/sobject/query_paginated_first_page_response.json +14 -0
  49. data/spec/fixtures/sobject/query_paginated_last_page_response.json +13 -0
  50. data/spec/fixtures/sobject/query_success_response.json +38 -0
  51. data/spec/fixtures/sobject/recent_success_response.json +18 -0
  52. data/spec/fixtures/sobject/search_error_response.json +6 -0
  53. data/spec/fixtures/sobject/search_success_response.json +16 -0
  54. data/spec/fixtures/sobject/sobject_describe_error_response.json +6 -0
  55. data/spec/fixtures/sobject/sobject_describe_success_response.json +1429 -0
  56. data/spec/fixtures/sobject/sobject_find_error_response.json +6 -0
  57. data/spec/fixtures/sobject/sobject_find_success_response.json +29 -0
  58. data/spec/fixtures/sobject/upsert_created_success_response.json +5 -0
  59. data/spec/fixtures/sobject/upsert_error_response.json +6 -0
  60. data/spec/fixtures/sobject/upsert_multiple_error_response.json +4 -0
  61. data/spec/fixtures/sobject/upsert_updated_success_response.json +0 -0
  62. data/spec/fixtures/sobject/write_error_response.json +6 -0
  63. data/spec/integration/abstract_client_spec.rb +306 -0
  64. data/spec/integration/data/client_spec.rb +90 -0
  65. data/spec/spec_helper.rb +20 -0
  66. data/spec/support/client_integration.rb +45 -0
  67. data/spec/support/concerns.rb +18 -0
  68. data/spec/support/event_machine.rb +14 -0
  69. data/spec/support/fixture_helpers.rb +45 -0
  70. data/spec/support/matchers.rb +11 -0
  71. data/spec/support/middleware.rb +76 -0
  72. data/spec/support/mock_cache.rb +13 -0
  73. data/spec/unit/abstract_client_spec.rb +11 -0
  74. data/spec/unit/attachment_spec.rb +15 -0
  75. data/spec/unit/collection_spec.rb +52 -0
  76. data/spec/unit/concerns/api_spec.rb +244 -0
  77. data/spec/unit/concerns/authentication_spec.rb +98 -0
  78. data/spec/unit/concerns/base_spec.rb +42 -0
  79. data/spec/unit/concerns/caching_spec.rb +29 -0
  80. data/spec/unit/concerns/canvas_spec.rb +30 -0
  81. data/spec/unit/concerns/connection_spec.rb +22 -0
  82. data/spec/unit/config_spec.rb +99 -0
  83. data/spec/unit/data/client_spec.rb +10 -0
  84. data/spec/unit/mash_spec.rb +36 -0
  85. data/spec/unit/middleware/authentication/password_spec.rb +31 -0
  86. data/spec/unit/middleware/authentication/token_spec.rb +24 -0
  87. data/spec/unit/middleware/authentication_spec.rb +67 -0
  88. data/spec/unit/middleware/authorization_spec.rb +11 -0
  89. data/spec/unit/middleware/gzip_spec.rb +66 -0
  90. data/spec/unit/middleware/instance_url_spec.rb +24 -0
  91. data/spec/unit/middleware/logger_spec.rb +19 -0
  92. data/spec/unit/middleware/mashify_spec.rb +11 -0
  93. data/spec/unit/middleware/raise_error_spec.rb +32 -0
  94. data/spec/unit/signed_request_spec.rb +24 -0
  95. data/spec/unit/sobject_spec.rb +86 -0
  96. data/spec/unit/tooling/client_spec.rb +7 -0
  97. metadata +225 -65
@@ -0,0 +1,6 @@
1
+ [
2
+ {
3
+ "message": "Provided external ID field does not exist or is not accessible: 23foo",
4
+ "errorCode": "NOT_FOUND"
5
+ }
6
+ ]
@@ -0,0 +1,29 @@
1
+ {
2
+ "attributes": {
3
+ "type": "Whizbang",
4
+ "url": "/services/data/v20.0/sobjects/Whizbang/23foo"
5
+ },
6
+ "Id": "23foo",
7
+ "OwnerId": "owner_id",
8
+ "IsDeleted": false,
9
+ "Name": "My First Whizbang",
10
+ "CreatedById": "created_by_id",
11
+ "LastModifiedById": "last_modified_by_id",
12
+ "Auto_Number": "A-1",
13
+ "Checkbox_Label": true,
14
+ "Currency_Label": 23.0,
15
+ "Date_Label": "2010-01-01",
16
+ "DateTime_Label": "2011-07-07T00:37:00.000+0000",
17
+ "OtherDateTime_Label": null,
18
+ "Email_Label": "danny@example.com",
19
+ "Number_Label": 23.0,
20
+ "Percent_Label": 33.0,
21
+ "Phone_Label": "(415) 555-1212",
22
+ "Picklist_Label": "one",
23
+ "Picklist_Multiselect_Label": "four;six",
24
+ "Text_Label": "some text",
25
+ "TextArea_Label": "a text area",
26
+ "TextAreaLong_Label": "a loooooooooooooong text area",
27
+ "TextAreaRich_Label": "Rich <strong>text</strong>",
28
+ "URL_Label": "http://pivotallabs.com"
29
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "id": "foo",
3
+ "errors": [],
4
+ "success": true
5
+ }
@@ -0,0 +1,6 @@
1
+ [
2
+ {
3
+ "message": "Provided external ID field does not exist or is not accessible: Namez",
4
+ "errorCode": "NOT_FOUND"
5
+ }
6
+ ]
@@ -0,0 +1,4 @@
1
+ [
2
+ "/services/data/v23.0/sobjects/Whizbang/foo",
3
+ "/services/data/v23.0/sobjects/Whizbang/bar"
4
+ ]
@@ -0,0 +1,6 @@
1
+ [
2
+ {
3
+ "message": "No such column 'foo' on sobject of type Bar",
4
+ "errorCode": "INVALID_FIELD"
5
+ }
6
+ ]
@@ -0,0 +1,306 @@
1
+ require 'spec_helper'
2
+
3
+ shared_examples_for Sfdc::AbstractClient do
4
+ describe '.list_sobjects' do
5
+ requests :sobjects, :fixture => 'sobject/describe_sobjects_success_response'
6
+
7
+ subject { client.list_sobjects }
8
+ it { should be_an Array }
9
+ it { should eq ['Account'] }
10
+ end
11
+
12
+ describe '.describe' do
13
+ context 'with no arguments' do
14
+ requests :sobjects, :fixture => 'sobject/describe_sobjects_success_response'
15
+
16
+ subject { client.describe }
17
+ it { should be_an Array }
18
+ end
19
+
20
+ context 'with an argument' do
21
+ requests 'sobjects/Whizbang/describe', :fixture => 'sobject/sobject_describe_success_response'
22
+
23
+ subject { client.describe('Whizbang') }
24
+ its(['name']) { should eq 'Whizbang' }
25
+ end
26
+ end
27
+
28
+ describe '.query' do
29
+ requests 'query\?q=SELECT%20some,%20fields%20FROM%20object', :fixture => 'sobject/query_success_response'
30
+
31
+ subject { client.query('SELECT some, fields FROM object') }
32
+ it { should be_an Enumerable }
33
+ end
34
+
35
+ describe '.search' do
36
+ requests 'search\?q=FIND%20%7Bbar%7D', :fixture => 'sobject/search_success_response'
37
+
38
+ subject { client.search('FIND {bar}') }
39
+ it { should be_an Array }
40
+ its(:size) { should eq 2 }
41
+ end
42
+
43
+ describe '.org_id' do
44
+ requests 'query\?q=select%20id%20from%20Organization', :fixture => 'sobject/org_query_response'
45
+
46
+ subject { client.org_id }
47
+ it { should eq '00Dx0000000BV7z' }
48
+ end
49
+
50
+ describe '.create' do
51
+ context 'without multipart' do
52
+ requests 'sobjects/Account',
53
+ :method => :post,
54
+ :with_body => "{\"Name\":\"Foobar\"}",
55
+ :fixture => 'sobject/create_success_response'
56
+
57
+ subject { client.create('Account', :Name => 'Foobar') }
58
+ it { should eq 'some_id' }
59
+ end
60
+
61
+ context 'with multipart' do
62
+ requests 'sobjects/Account',
63
+ :method => :post,
64
+ :with_body => %r(----boundary_string\r\nContent-Disposition: form-data; name=\"entity_content\";\r\nContent-Type: application/json\r\n\r\n{\"Name\":\"Foobar\"}\r\n----boundary_string\r\nContent-Disposition: form-data; name=\"Blob\"; filename=\"blob.jpg\"\r\nContent-Length: 42171\r\nContent-Type: image/jpeg\r\nContent-Transfer-Encoding: binary),
65
+ :fixture => 'sobject/create_success_response'
66
+
67
+ subject { client.create('Account', :Name => 'Foobar', :Blob => Sfdc::UploadIO.new(File.expand_path('../../fixtures/blob.jpg', __FILE__), 'image/jpeg')) }
68
+ it { should eq 'some_id' }
69
+ end
70
+ end
71
+
72
+ describe '.update!' do
73
+ context 'with invalid Id' do
74
+ requests 'sobjects/Account/001D000000INjVe',
75
+ :method => :patch,
76
+ :with_body => "{\"Name\":\"Foobar\"}",
77
+ :status => 404,
78
+ :fixture => 'sobject/delete_error_response'
79
+
80
+ subject { lambda { client.update!('Account', :Id => '001D000000INjVe', :Name => 'Foobar') } }
81
+ it { should raise_error Faraday::Error::ResourceNotFound }
82
+ end
83
+ end
84
+
85
+ describe '.update' do
86
+ context 'with missing Id' do
87
+ subject { lambda { client.update('Account', :Name => 'Foobar') } }
88
+ it { should raise_error ArgumentError, 'Id field missing from attrs.' }
89
+ end
90
+
91
+ context 'with invalid Id' do
92
+ requests 'sobjects/Account/001D000000INjVe',
93
+ :method => :patch,
94
+ :with_body => "{\"Name\":\"Foobar\"}",
95
+ :status => 404,
96
+ :fixture => 'sobject/delete_error_response'
97
+
98
+ subject { client.update('Account', :Id => '001D000000INjVe', :Name => 'Foobar') }
99
+ it { should be_false }
100
+ end
101
+
102
+ context 'with success' do
103
+ requests 'sobjects/Account/001D000000INjVe',
104
+ :method => :patch,
105
+ :with_body => "{\"Name\":\"Foobar\"}"
106
+
107
+ [:Id, :id, 'Id', 'id'].each do |key|
108
+ context "with #{key.inspect} as the key" do
109
+ subject { client.update('Account', key => '001D000000INjVe', :Name => 'Foobar') }
110
+ it { should be_true }
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ describe '.upsert!' do
117
+ context 'when updated' do
118
+ requests 'sobjects/Account/External__c/foobar',
119
+ :method => :patch,
120
+ :with_body => "{\"Name\":\"Foobar\"}"
121
+
122
+ context 'with symbol external Id key' do
123
+ subject { client.upsert!('Account', 'External__c', :External__c => 'foobar', :Name => 'Foobar') }
124
+ it { should be_true }
125
+ end
126
+
127
+ context 'with string external Id key' do
128
+ subject { client.upsert!('Account', 'External__c', 'External__c' => 'foobar', 'Name' => 'Foobar') }
129
+ it { should be_true }
130
+ end
131
+ end
132
+
133
+ context 'when created' do
134
+ requests 'sobjects/Account/External__c/foobar',
135
+ :method => :patch,
136
+ :with_body => "{\"Name\":\"Foobar\"}",
137
+ :fixture => 'sobject/upsert_created_success_response'
138
+
139
+ [:External__c, 'External__c', :external__c, 'external__c'].each do |key|
140
+ context "with #{key.inspect} as the external id" do
141
+ subject { client.upsert!('Account', 'External__c', key => 'foobar', :Name => 'Foobar') }
142
+ it { should eq 'foo' }
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ describe '.destroy!' do
149
+ subject(:destroy!) { client.destroy!('Account', '001D000000INjVe') }
150
+
151
+ context 'with invalid Id' do
152
+ requests 'sobjects/Account/001D000000INjVe',
153
+ :fixture => 'sobject/delete_error_response',
154
+ :method => :delete,
155
+ :status => 404
156
+
157
+ subject { lambda { destroy! } }
158
+ it { should raise_error Faraday::Error::ResourceNotFound }
159
+ end
160
+
161
+ context 'with success' do
162
+ requests 'sobjects/Account/001D000000INjVe', :method => :delete
163
+
164
+ it { should be_true }
165
+ end
166
+ end
167
+
168
+ describe '.destroy' do
169
+ subject { client.destroy('Account', '001D000000INjVe') }
170
+
171
+ context 'with invalid Id' do
172
+ requests 'sobjects/Account/001D000000INjVe',
173
+ :fixture => 'sobject/delete_error_response',
174
+ :method => :delete,
175
+ :status => 404
176
+
177
+ it { should be_false }
178
+ end
179
+
180
+ context 'with success' do
181
+ requests 'sobjects/Account/001D000000INjVe', :method => :delete
182
+
183
+ it { should be_true }
184
+ end
185
+ end
186
+
187
+ describe '.find' do
188
+ context 'with no external id passed' do
189
+ requests 'sobjects/Account/001D000000INjVe',
190
+ :fixture => 'sobject/sobject_find_success_response'
191
+
192
+ subject { client.find('Account', '001D000000INjVe') }
193
+ it { should be_a Hash }
194
+ end
195
+
196
+ context 'when an external id is passed' do
197
+ requests 'sobjects/Account/External_Field__c/1234',
198
+ :fixture => 'sobject/sobject_find_success_response'
199
+
200
+ subject { client.find('Account', '1234', 'External_Field__c') }
201
+ it { should be_a Hash }
202
+ end
203
+ end
204
+
205
+ describe '.authenticate!' do
206
+ subject(:authenticate!) { client.authenticate! }
207
+
208
+ context 'when successful' do
209
+ before do
210
+ @request = stub_login_request(:with_body => "grant_type=password&client_id=client_id&client_secret=" \
211
+ "client_secret&username=foo&password=barsecurity_token").
212
+ to_return(:status => 200, :body => fixture(:auth_success_response))
213
+ end
214
+
215
+ after do
216
+ expect(@request).to have_been_requested
217
+ end
218
+
219
+ it { should be_a Hash }
220
+ end
221
+
222
+ context 'when no authentication middleware is present' do
223
+ before do
224
+ client.stub(:authentication_middleware).and_return(nil)
225
+ end
226
+
227
+ subject { lambda { authenticate! } }
228
+ it { should raise_error Sfdc::AuthenticationError, 'No authentication middleware present'}
229
+ end
230
+ end
231
+
232
+ describe '.without_caching' do
233
+ requests 'query\?q=SELECT%20some,%20fields%20FROM%20object',
234
+ :fixture => 'sobject/query_success_response'
235
+
236
+ before do
237
+ cache.should_receive(:delete).and_call_original
238
+ cache.should_receive(:fetch).and_call_original
239
+ end
240
+
241
+ let(:cache) { MockCache.new }
242
+ subject { client.without_caching { client.query('SELECT some, fields FROM object') } }
243
+ it { should be_an Enumerable }
244
+ end
245
+
246
+ describe 'authentication retries' do
247
+ context 'when retries reaches 0' do
248
+ before do
249
+ @auth_request = stub_api_request('query\?q=SELECT%20some,%20fields%20FROM%20object',
250
+ :status => 401,
251
+ :fixture => 'expired_session_response')
252
+ @query_request = stub_login_request(:with_body => "grant_type=password&client_id=client_id&client_secret=" \
253
+ "client_secret&username=foo&password=barsecurity_token").
254
+ to_return(:status => 200, :body => fixture(:auth_success_response))
255
+ end
256
+
257
+ subject { lambda { client.query('SELECT some, fields FROM object') } }
258
+ it { should raise_error Sfdc::UnauthorizedError }
259
+ end
260
+ end
261
+
262
+ describe '.query with caching' do
263
+ let(:cache) { MockCache.new }
264
+
265
+ before do
266
+ @query = stub_api_request('query\?q=SELECT%20some,%20fields%20FROM%20object').
267
+ with(:headers => { 'Authorization' => "OAuth #{oauth_token}" }).
268
+ to_return(:status => 401, :body => fixture('expired_session_response'), :headers => { 'Content-Type' => 'application/json' }).then.
269
+ to_return(:status => 200, :body => fixture('sobject/query_success_response'), :headers => { 'Content-Type' => 'application/json' })
270
+
271
+ @login = stub_login_request(:with_body => "grant_type=password&client_id=client_id&client_secret=" \
272
+ "client_secret&username=foo&password=barsecurity_token").
273
+ to_return(:status => 200, :body => fixture(:auth_success_response))
274
+ end
275
+
276
+ after do
277
+ expect(@query).to have_been_made.times(2)
278
+ expect(@login).to have_been_made
279
+ end
280
+
281
+ subject { client.query('SELECT some, fields FROM object') }
282
+ it { should be_an Enumerable }
283
+ end
284
+ end
285
+
286
+ describe Sfdc::AbstractClient do
287
+ describe 'with mashify' do
288
+ it_behaves_like Sfdc::AbstractClient
289
+
290
+ describe '.query' do
291
+ context 'with pagination' do
292
+ subject { client.query('SELECT some, fields FROM object').next_page }
293
+
294
+ requests 'query\?q', :fixture => 'sobject/query_paginated_first_page_response'
295
+ requests 'query/01gD', :fixture => 'sobject/query_paginated_last_page_response'
296
+
297
+ it { should be_a Sfdc::Collection }
298
+ its('first.Text_Label') { should eq 'Last Page'}
299
+ end
300
+ end
301
+ end
302
+
303
+ describe 'without mashify', :mashify => false do
304
+ it_behaves_like Sfdc::AbstractClient
305
+ end
306
+ end
@@ -0,0 +1,90 @@
1
+ require 'spec_helper'
2
+
3
+ shared_examples_for Sfdc::Data::Client do
4
+ describe '.picklist_values' do
5
+ requests 'sobjects/Account/describe',
6
+ :fixture => 'sobject/sobject_describe_success_response'
7
+
8
+ context 'when given a picklist field' do
9
+ subject { client.picklist_values('Account', 'Picklist_Field') }
10
+ it { should be_an Array }
11
+ its(:length) { should eq 3 }
12
+ it { should include_picklist_values ['one', 'two', 'three'] }
13
+ end
14
+
15
+ context 'when given a multipicklist field' do
16
+ subject { client.picklist_values('Account', 'Picklist_Multiselect_Field') }
17
+ it { should be_an Array }
18
+ its(:length) { should eq 3 }
19
+ it { should include_picklist_values ['four', 'five', 'six'] }
20
+ end
21
+
22
+ describe 'dependent picklists' do
23
+ context 'when given a picklist field that has a dependency' do
24
+ subject { client.picklist_values('Account', 'Dependent_Picklist_Field', :valid_for => 'one') }
25
+ it { should be_an Array }
26
+ its(:length) { should eq 2 }
27
+ it { should include_picklist_values ['seven', 'eight'] }
28
+ it { should_not include_picklist_values ['nine'] }
29
+ end
30
+
31
+ context 'when given a picklist field that does not have a dependency' do
32
+ subject { client.picklist_values('Account', 'Picklist_Field', :valid_for => 'one') }
33
+ it 'raises an exception' do
34
+ expect { subject }.to raise_error(/Picklist_Field is not a dependent picklist/)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ describe '.faye', :event_machine => true do
41
+ subject { client.faye }
42
+
43
+ context 'with missing instance url' do
44
+ let(:instance_url) { nil }
45
+ specify { expect { subject }.to raise_error RuntimeError, 'Instance URL missing. Call .authenticate! first.' }
46
+ end
47
+
48
+ context 'with oauth token and instance url' do
49
+ let(:instance_url) { 'http://google.com' }
50
+ let(:oauth_token) { 'bar' }
51
+ specify { expect { subject }.to_not raise_error }
52
+ end
53
+
54
+ context 'when the connection goes down' do
55
+ it 'should reauthenticate' do
56
+ access_token = double('access token')
57
+ access_token.stub(:access_token).and_return('token')
58
+ client.should_receive(:authenticate!).and_return(access_token)
59
+ client.faye.should_receive(:set_header).with('Authorization', "OAuth token")
60
+ client.faye.trigger('transport:down')
61
+ end
62
+ end
63
+ end
64
+
65
+ describe '.subcribe', :event_machine => true do
66
+ context 'when given a single pushtopic' do
67
+ it 'subscribes to the pushtopic' do
68
+ client.faye.should_receive(:subscribe).with(['/topic/PushTopic'])
69
+ client.subscribe('PushTopic')
70
+ end
71
+ end
72
+
73
+ context 'when given an array of pushtopics' do
74
+ it 'subscribes to each pushtopic' do
75
+ client.faye.should_receive(:subscribe).with(['/topic/PushTopic1', '/topic/PushTopic2'])
76
+ client.subscribe(['PushTopic1', 'PushTopic2'])
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ describe Sfdc::Data::Client do
83
+ describe 'with mashify' do
84
+ it_behaves_like Sfdc::Client
85
+ end
86
+
87
+ describe 'without mashify', :mashify => false do
88
+ it_behaves_like Sfdc::Client
89
+ end
90
+ end