files.com 1.1.768 → 1.1.769
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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +47 -7
- data/_VERSION +1 -1
- data/docs/file.md +37 -0
- data/lib/files.com/api.rb +9 -4
- data/lib/files.com/api_client.rb +181 -55
- data/lib/files.com/list.rb +98 -66
- data/lib/files.com/models/action_log.rb +2 -2
- data/lib/files.com/models/action_notification_export.rb +3 -3
- data/lib/files.com/models/action_notification_export_result.rb +2 -2
- data/lib/files.com/models/ai_assistant_personality.rb +10 -8
- data/lib/files.com/models/ai_task.rb +10 -8
- data/lib/files.com/models/api_key.rb +12 -10
- data/lib/files.com/models/api_request_log.rb +2 -2
- data/lib/files.com/models/app.rb +2 -2
- data/lib/files.com/models/as2_incoming_message.rb +2 -2
- data/lib/files.com/models/as2_outgoing_message.rb +2 -2
- data/lib/files.com/models/as2_partner.rb +10 -8
- data/lib/files.com/models/as2_station.rb +10 -8
- data/lib/files.com/models/automation.rb +12 -10
- data/lib/files.com/models/automation_log.rb +2 -2
- data/lib/files.com/models/automation_run.rb +6 -6
- data/lib/files.com/models/bandwidth_snapshot.rb +2 -2
- data/lib/files.com/models/behavior.rb +12 -10
- data/lib/files.com/models/bundle.rb +10 -8
- data/lib/files.com/models/bundle_action.rb +2 -2
- data/lib/files.com/models/bundle_download.rb +2 -2
- data/lib/files.com/models/bundle_notification.rb +10 -8
- data/lib/files.com/models/bundle_recipient.rb +4 -4
- data/lib/files.com/models/bundle_registration.rb +2 -2
- data/lib/files.com/models/chat_session.rb +3 -3
- data/lib/files.com/models/child_site_management_policy.rb +10 -8
- data/lib/files.com/models/clickwrap.rb +10 -8
- data/lib/files.com/models/custom_domain.rb +11 -9
- data/lib/files.com/models/desktop_configuration_profile.rb +10 -8
- data/lib/files.com/models/dns_record.rb +2 -2
- data/lib/files.com/models/email_incoming_message.rb +2 -2
- data/lib/files.com/models/email_log.rb +2 -2
- data/lib/files.com/models/event_channel.rb +10 -8
- data/lib/files.com/models/event_delivery_attempt.rb +3 -3
- data/lib/files.com/models/event_record.rb +3 -3
- data/lib/files.com/models/event_subscription.rb +10 -8
- data/lib/files.com/models/event_target.rb +10 -8
- data/lib/files.com/models/exavault_api_request_log.rb +2 -2
- data/lib/files.com/models/expectation.rb +11 -9
- data/lib/files.com/models/expectation_evaluation.rb +3 -3
- data/lib/files.com/models/expectation_incident.rb +6 -6
- data/lib/files.com/models/external_event.rb +5 -5
- data/lib/files.com/models/file.rb +23 -15
- data/lib/files.com/models/file_comment.rb +9 -7
- data/lib/files.com/models/file_comment_reaction.rb +2 -2
- data/lib/files.com/models/file_migration.rb +1 -1
- data/lib/files.com/models/file_migration_log.rb +2 -2
- data/lib/files.com/models/folder.rb +3 -3
- data/lib/files.com/models/form_field_set.rb +10 -8
- data/lib/files.com/models/ftp_action_log.rb +2 -2
- data/lib/files.com/models/gpg_key.rb +10 -8
- data/lib/files.com/models/group.rb +10 -8
- data/lib/files.com/models/group_user.rb +9 -7
- data/lib/files.com/models/history.rb +10 -10
- data/lib/files.com/models/history_export.rb +3 -3
- data/lib/files.com/models/history_export_result.rb +2 -2
- data/lib/files.com/models/holiday_calendar.rb +10 -8
- data/lib/files.com/models/holiday_region.rb +2 -2
- data/lib/files.com/models/inbound_s3_log.rb +2 -2
- data/lib/files.com/models/inbox_recipient.rb +4 -4
- data/lib/files.com/models/inbox_registration.rb +2 -2
- data/lib/files.com/models/inbox_upload.rb +2 -2
- data/lib/files.com/models/integration_centric_profile.rb +10 -8
- data/lib/files.com/models/invoice.rb +3 -3
- data/lib/files.com/models/ip_address.rb +8 -8
- data/lib/files.com/models/key_lifecycle_rule.rb +10 -8
- data/lib/files.com/models/lock.rb +3 -3
- data/lib/files.com/models/metadata_category.rb +12 -10
- data/lib/files.com/models/notification.rb +10 -8
- data/lib/files.com/models/outbound_connection_log.rb +2 -2
- data/lib/files.com/models/partner.rb +10 -8
- data/lib/files.com/models/partner_channel.rb +10 -8
- data/lib/files.com/models/partner_channel_template.rb +10 -8
- data/lib/files.com/models/partner_site_request.rb +4 -4
- data/lib/files.com/models/payment.rb +3 -3
- data/lib/files.com/models/pending_work_event.rb +3 -3
- data/lib/files.com/models/permission.rb +4 -4
- data/lib/files.com/models/public_hosting_request_log.rb +2 -2
- data/lib/files.com/models/public_key.rb +10 -8
- data/lib/files.com/models/remote_bandwidth_snapshot.rb +2 -2
- data/lib/files.com/models/remote_mount_backend.rb +10 -8
- data/lib/files.com/models/remote_server.rb +13 -11
- data/lib/files.com/models/remote_server_credential.rb +10 -8
- data/lib/files.com/models/request.rb +6 -6
- data/lib/files.com/models/restore.rb +4 -4
- data/lib/files.com/models/schedule.rb +10 -8
- data/lib/files.com/models/scheduled_export.rb +10 -8
- data/lib/files.com/models/scim_log.rb +3 -3
- data/lib/files.com/models/secret.rb +10 -8
- data/lib/files.com/models/session.rb +2 -2
- data/lib/files.com/models/settings_change.rb +2 -2
- data/lib/files.com/models/sftp_action_log.rb +2 -2
- data/lib/files.com/models/sftp_host_key.rb +10 -8
- data/lib/files.com/models/share_group.rb +10 -8
- data/lib/files.com/models/siem_http_destination.rb +10 -8
- data/lib/files.com/models/siem_http_destination_event.rb +3 -3
- data/lib/files.com/models/site.rb +3 -3
- data/lib/files.com/models/site_subdomain_redirect.rb +3 -3
- data/lib/files.com/models/snapshot.rb +10 -8
- data/lib/files.com/models/sso_event.rb +3 -3
- data/lib/files.com/models/sso_strategy.rb +3 -3
- data/lib/files.com/models/style.rb +6 -5
- data/lib/files.com/models/sync.rb +10 -8
- data/lib/files.com/models/sync_log.rb +2 -2
- data/lib/files.com/models/sync_run.rb +3 -3
- data/lib/files.com/models/usage_daily_snapshot.rb +2 -2
- data/lib/files.com/models/usage_snapshot.rb +2 -2
- data/lib/files.com/models/user.rb +10 -8
- data/lib/files.com/models/user_additional_email_recipient.rb +10 -8
- data/lib/files.com/models/user_cipher_use.rb +2 -2
- data/lib/files.com/models/user_lifecycle_rule.rb +10 -8
- data/lib/files.com/models/user_request.rb +5 -5
- data/lib/files.com/models/user_security_event.rb +3 -3
- data/lib/files.com/models/user_sftp_client_use.rb +2 -2
- data/lib/files.com/models/web_dav_action_log.rb +2 -2
- data/lib/files.com/models/webhook_test.rb +2 -2
- data/lib/files.com/models/workspace.rb +10 -8
- data/lib/files.com/response.rb +24 -0
- data/lib/files.com/sizable_io.rb +65 -4
- data/lib/files.com/version.rb +1 -1
- data/lib/files.com.rb +23 -1
- data/spec/language_spec.rb +20 -10
- data/spec/lib/api_client_spec.rb +173 -18
- data/spec/list_spec.rb +203 -187
- data/spec/models/file_transfer_spec.rb +346 -0
- data/spec/models/persistence_spec.rb +167 -0
- data/spec/spec_helper.rb +122 -6
- data/spec/support/stubbed_api.rb +14 -0
- data/spec/support/transfer_server.rb +60 -0
- metadata +5 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "stringio"
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require_relative "../support/transfer_server"
|
|
6
|
+
|
|
7
|
+
RSpec.describe Files::File, "transfers" do
|
|
8
|
+
# 22,528 bytes that include every byte value, so text handling would show.
|
|
9
|
+
let(:payload) { (0..255).map(&:chr).join.b * 88 }
|
|
10
|
+
let(:signed_target) { "/objects/report.bin?X-Amz-Credential=fixture%2Fcredential&X-Amz-Signature=fixture-signature" }
|
|
11
|
+
let(:sink) { StringIO.new("".b) }
|
|
12
|
+
let(:servers) { [] }
|
|
13
|
+
# A fixture server that pops this waits until the example closes it.
|
|
14
|
+
let(:server_gate) { Queue.new }
|
|
15
|
+
|
|
16
|
+
around do |example|
|
|
17
|
+
original = [ Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id, Files.workspace_id ]
|
|
18
|
+
Files.api_key = "fixture-api-key"
|
|
19
|
+
Files.max_network_retries = 0
|
|
20
|
+
Files.session_id = "fixture-session"
|
|
21
|
+
Files.workspace_id = 7
|
|
22
|
+
example.run
|
|
23
|
+
ensure
|
|
24
|
+
Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id, Files.workspace_id = original
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
after do
|
|
28
|
+
server_gate.close
|
|
29
|
+
servers.each(&:close)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def serve(response = nil, &respond)
|
|
33
|
+
respond ||= ->(client) { client.write(response) }
|
|
34
|
+
TransferServer.new(&respond).tap { |server| servers << server }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def remote_file(server)
|
|
38
|
+
described_class.new(path: "report.bin", download_uri: server.url(signed_target))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def response(...)
|
|
42
|
+
TransferServer.response(...)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fixed_length(body, declared_bytes: body.bytesize)
|
|
46
|
+
response("200 OK", { "Content-Length" => declared_bytes }, body)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A body in chunked transfer coding. An incomplete one stops before the final
|
|
50
|
+
# zero-length chunk, as when the connection drops.
|
|
51
|
+
def chunked(*chunks, complete: true)
|
|
52
|
+
encoded = chunks.map { |chunk| "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" }.join
|
|
53
|
+
complete ? "#{encoded}0\r\n\r\n" : encoded
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe ".upload_file" do
|
|
57
|
+
it "raises the filesystem error when the local source does not exist" do
|
|
58
|
+
Dir.mktmpdir do |dir|
|
|
59
|
+
missing_path = File.join(dir, "missing.bin")
|
|
60
|
+
|
|
61
|
+
expect { described_class.upload_file(missing_path, "report.bin") }.to raise_error(Errno::ENOENT)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe "#download_content" do
|
|
67
|
+
def download(raw_response, range: [])
|
|
68
|
+
server = serve(raw_response)
|
|
69
|
+
remote_file(server).download_content(sink, range: range)
|
|
70
|
+
server
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "writes a fixed-length body exactly and leaves the caller's IO open" do
|
|
74
|
+
download(fixed_length(payload))
|
|
75
|
+
|
|
76
|
+
expect(sink.string).to eq(payload)
|
|
77
|
+
expect(sink).not_to be_closed
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "writes a gzip body decompressed, although Content-Length counts compressed bytes" do
|
|
81
|
+
zipped = Zlib.gzip(payload)
|
|
82
|
+
|
|
83
|
+
download(response("200 OK", { "Content-Encoding" => "gzip", "Content-Length" => zipped.bytesize }, zipped))
|
|
84
|
+
|
|
85
|
+
expect(sink.string).to eq(payload)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "writes the requested range of a partial-content response" do
|
|
89
|
+
part = payload.byteslice(4, 8)
|
|
90
|
+
|
|
91
|
+
server = download(response("206 Partial Content", { "Content-Range" => "bytes 4-11/#{payload.bytesize}", "Content-Length" => 8 }, part), range: [ 4, 11 ])
|
|
92
|
+
|
|
93
|
+
expect(sink.string).to eq(part)
|
|
94
|
+
expect(server.requests.map { |request| request.headers["range"] }).to eq([ "bytes=4-11" ])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "writes a gzip-encoded range exactly as sent, without decompressing it" do
|
|
98
|
+
part = Zlib.gzip(payload).byteslice(0, 50)
|
|
99
|
+
|
|
100
|
+
download(response("206 Partial Content", { "Content-Encoding" => "gzip", "Content-Range" => "bytes 0-49/451", "Content-Length" => 50 }, part), range: [ 0, 49 ])
|
|
101
|
+
|
|
102
|
+
expect(sink.string).to eq(part)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "writes a chunked body" do
|
|
106
|
+
download(response("200 OK", { "Transfer-Encoding" => "chunked" }, chunked(payload.byteslice(0, 4096), payload.byteslice(4096..))))
|
|
107
|
+
|
|
108
|
+
expect(sink.string).to eq(payload)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "writes a body that ends when the server closes the connection" do
|
|
112
|
+
download(response("200 OK", {}, payload))
|
|
113
|
+
|
|
114
|
+
expect(sink.string).to eq(payload)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "accepts an empty body" do
|
|
118
|
+
download(fixed_length(""))
|
|
119
|
+
|
|
120
|
+
expect(sink.string).to be_empty
|
|
121
|
+
expect(sink).not_to be_closed
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "requests the signed URL exactly as issued, without Files.com credentials" do
|
|
125
|
+
server = download(fixed_length(payload))
|
|
126
|
+
|
|
127
|
+
expect(server.requests.map(&:target)).to eq([ signed_target ])
|
|
128
|
+
expect(server.requests.first.headers.keys).not_to include("authorization", "x-filesapi-key", "x-filesapi-auth", "x-files-workspace-id")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "raises EOFError when a fixed-length body ends early and leaves the caller's IO open for a retry" do
|
|
132
|
+
server = serve(fixed_length(payload.byteslice(0, 4096), declared_bytes: payload.bytesize))
|
|
133
|
+
|
|
134
|
+
expect { remote_file(server).download_content(sink) }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
|
|
135
|
+
expect(server.requests.size).to eq(1)
|
|
136
|
+
expect(sink).not_to be_closed
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "raises EOFError when a partial-content body ends early" do
|
|
140
|
+
server = serve(response("206 Partial Content", { "Content-Range" => "bytes 4-11/#{payload.bytesize}", "Content-Length" => 8 }, payload.byteslice(4, 4)))
|
|
141
|
+
|
|
142
|
+
expect { remote_file(server).download_content(sink, range: [ 4, 11 ]) }.to raise_error(EOFError, "Transfer response ended after 4 of 8 bytes")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "raises EOFError when a gzip-encoded range ends early, since Net::HTTP passes ranges through as sent" do
|
|
146
|
+
part = Zlib.gzip(payload).byteslice(0, 50)
|
|
147
|
+
server = serve(response("206 Partial Content", { "Content-Encoding" => "gzip", "Content-Range" => "bytes 0-49/451", "Content-Length" => 50 }, part.byteslice(0, 25)))
|
|
148
|
+
|
|
149
|
+
expect { remote_file(server).download_content(sink, range: [ 0, 49 ]) }.to raise_error(EOFError, "Transfer response ended after 25 of 50 bytes")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "raises EOFError when an identity-encoded body ends early" do
|
|
153
|
+
server = serve(response("200 OK", { "Content-Encoding" => "identity", "Content-Length" => payload.bytesize }, payload.byteslice(0, 4096)))
|
|
154
|
+
|
|
155
|
+
expect { remote_file(server).download_content(sink) }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "raises EOFError when a chunked body ends early, without requesting and writing it again" do
|
|
159
|
+
first_chunk = payload.byteslice(0, 4096)
|
|
160
|
+
server = serve(response("200 OK", { "Transfer-Encoding" => "chunked" }, chunked(first_chunk, complete: false)))
|
|
161
|
+
|
|
162
|
+
expect { remote_file(server).download_content(sink) }.to raise_error(EOFError)
|
|
163
|
+
expect(server.requests.size).to eq(1)
|
|
164
|
+
expect(sink.string).to eq(first_chunk)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "raises for a refused transfer with an empty body and closes the caller's IO, without revealing the signed URL" do
|
|
168
|
+
server = serve(response("503 Service Unavailable", { "Content-Length" => 0 }))
|
|
169
|
+
|
|
170
|
+
expect { remote_file(server).download_content(sink) }.to raise_error(Net::HTTPFatalError, '503 "Service Unavailable"') { |error|
|
|
171
|
+
expect(error.full_message).not_to include("/objects/", "X-Amz", "fixture-signature")
|
|
172
|
+
}
|
|
173
|
+
expect(sink).to be_closed
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "does not write a refused transfer's error body" do
|
|
177
|
+
error_body = "<Error>Expired</Error>"
|
|
178
|
+
server = serve(response("403 Forbidden", { "Content-Length" => error_body.bytesize }, error_body))
|
|
179
|
+
|
|
180
|
+
expect { remote_file(server).download_content(sink) }.to raise_error(Net::HTTPClientException, '403 "Forbidden"')
|
|
181
|
+
expect(sink.string).to be_empty
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "raises the caller IO's own write error, even when closing that IO fails too" do
|
|
185
|
+
write_error = IOError.new("destination is full")
|
|
186
|
+
failing_sink = Class.new(StringIO) do
|
|
187
|
+
define_method(:<<) { |_bytes| raise write_error }
|
|
188
|
+
|
|
189
|
+
def close
|
|
190
|
+
super
|
|
191
|
+
raise IOError, "close failed too"
|
|
192
|
+
end
|
|
193
|
+
end.new
|
|
194
|
+
server = serve(fixed_length(payload))
|
|
195
|
+
|
|
196
|
+
expect { remote_file(server).download_content(failing_sink) }.to raise_error(IOError) { |error| expect(error).to equal(write_error) }
|
|
197
|
+
expect(failing_sink).to be_closed
|
|
198
|
+
expect(server.requests.size).to eq(1)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
describe "#download_file" do
|
|
203
|
+
it "raises when the body ends early and closes the local file it created" do
|
|
204
|
+
server = serve(fixed_length(payload.byteslice(0, 4096), declared_bytes: payload.bytesize))
|
|
205
|
+
|
|
206
|
+
Dir.mktmpdir do |dir|
|
|
207
|
+
local_path = File.join(dir, "report.bin")
|
|
208
|
+
|
|
209
|
+
expect { remote_file(server).download_file(local_path) }.to raise_error(EOFError)
|
|
210
|
+
expect(ObjectSpace.each_object(File).select { |file| file.path == local_path }).to all(be_closed)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
describe "#read_io" do
|
|
216
|
+
def open_pipes
|
|
217
|
+
ObjectSpace.each_object(Files::SizableIO).reject(&:closed?)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Returns a file whose read_io has received the first 4096 of 22,528 bytes.
|
|
221
|
+
# The connection then drops, or later when the example closes server_gate,
|
|
222
|
+
# so the download fails while the caller is reading.
|
|
223
|
+
def file_whose_download_fails_later(drop_connection: true)
|
|
224
|
+
server = serve do |client|
|
|
225
|
+
client.write(fixed_length(payload.byteslice(0, 4096), declared_bytes: payload.bytesize))
|
|
226
|
+
server_gate.pop
|
|
227
|
+
end
|
|
228
|
+
file = remote_file(server)
|
|
229
|
+
file.read_io
|
|
230
|
+
server_gate.close if drop_connection
|
|
231
|
+
file
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
{
|
|
235
|
+
"read" => ->(reader) { reader.read },
|
|
236
|
+
"read(n) until eof?" => ->(reader) { reader.read(1024) until reader.eof? },
|
|
237
|
+
"gets" => ->(reader) { nil while reader.gets },
|
|
238
|
+
"readline" => ->(reader) { loop { reader.readline } },
|
|
239
|
+
"readpartial" => ->(reader) { loop { reader.readpartial(1024) } },
|
|
240
|
+
"each_line" => ->(reader) { reader.each_line.to_a },
|
|
241
|
+
"each" => ->(reader) { reader.each(&:itself) },
|
|
242
|
+
}.each do |reading, read_to_end|
|
|
243
|
+
it "raises a download failure that happens after reading began through #{reading}, then closes quietly" do
|
|
244
|
+
reader = file_whose_download_fails_later.read_io
|
|
245
|
+
|
|
246
|
+
expect { read_to_end.call(reader) }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
|
|
247
|
+
expect { reader.close }.not_to raise_error
|
|
248
|
+
expect(reader).to be_closed
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it "raises from eof? a failure that happens after the last full read" do
|
|
253
|
+
reader = file_whose_download_fails_later(drop_connection: false).read_io
|
|
254
|
+
expect(reader.read(4096).bytesize).to eq(4096)
|
|
255
|
+
server_gate.close
|
|
256
|
+
|
|
257
|
+
expect { reader.eof? }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
|
|
258
|
+
expect { reader.close }.not_to raise_error
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it "raises a download failure that happens after reading began from Files::File#read" do
|
|
262
|
+
file = file_whose_download_fails_later
|
|
263
|
+
|
|
264
|
+
expect { file.read }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
|
|
265
|
+
ensure
|
|
266
|
+
file&.read_io&.close
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it "raises from close a failure that IO.copy_stream read past" do
|
|
270
|
+
reader = file_whose_download_fails_later.read_io
|
|
271
|
+
|
|
272
|
+
Dir.mktmpdir do |dir|
|
|
273
|
+
copied_bytes = File.open(File.join(dir, "report.bin"), "wb") { |copy| IO.copy_stream(reader, copy) }
|
|
274
|
+
expect(copied_bytes).to eq(4096)
|
|
275
|
+
end
|
|
276
|
+
expect { reader.close }.to raise_error(EOFError, "Transfer response ended after 4096 of 22528 bytes")
|
|
277
|
+
expect(reader).to be_closed
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it "raises from close a refusal that arrives after read_io stops waiting, releasing the pipe" do
|
|
281
|
+
server = serve do |client|
|
|
282
|
+
server_gate.pop
|
|
283
|
+
client.write(response("403 Forbidden", { "Content-Length" => 7 }, "refused"))
|
|
284
|
+
end
|
|
285
|
+
pipes_before = open_pipes
|
|
286
|
+
reader = remote_file(server).read_io # returns after its five-second wait for the first bytes
|
|
287
|
+
server_gate.close
|
|
288
|
+
|
|
289
|
+
nil while reader.getbyte
|
|
290
|
+
|
|
291
|
+
expect { reader.close }.to raise_error(Net::HTTPClientException, '403 "Forbidden"') { |close_error|
|
|
292
|
+
expect { reader.error! }.to raise_error(Net::HTTPClientException) { |stored_error| expect(stored_error).to equal(close_error) }
|
|
293
|
+
}
|
|
294
|
+
expect(open_pipes - pipes_before).to be_empty
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
it "does not let close replace an exception the caller is already raising" do
|
|
298
|
+
reader = file_whose_download_fails_later.read_io
|
|
299
|
+
callers_error = RuntimeError.new("caller failed")
|
|
300
|
+
|
|
301
|
+
expect {
|
|
302
|
+
begin
|
|
303
|
+
nil while reader.getbyte
|
|
304
|
+
raise callers_error
|
|
305
|
+
ensure
|
|
306
|
+
reader.close
|
|
307
|
+
end
|
|
308
|
+
}.to raise_error(RuntimeError) { |error| expect(error).to equal(callers_error) }
|
|
309
|
+
expect(reader).to be_closed
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it "stops downloading once the caller closes the reader" do
|
|
313
|
+
body_bytes = 32 * 1024 * 1024 # far more than socket and pipe buffers hold
|
|
314
|
+
block = ("x" * 65_536).b
|
|
315
|
+
sent_everything = false
|
|
316
|
+
server = serve do |client|
|
|
317
|
+
client.write(response("200 OK", { "Content-Length" => body_bytes }))
|
|
318
|
+
(body_bytes / block.bytesize).times { client.write(block) }
|
|
319
|
+
sent_everything = true
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
reader = remote_file(server).read_io
|
|
323
|
+
reader.read(1024)
|
|
324
|
+
reader.close
|
|
325
|
+
server.close # returns once the server has stopped writing
|
|
326
|
+
|
|
327
|
+
expect(sent_everything).to be(false)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it "raises a refused transfer and closes the pipe it created" do
|
|
331
|
+
server = serve(response("403 Forbidden", { "Content-Length" => 0 }))
|
|
332
|
+
pipes_before = open_pipes
|
|
333
|
+
|
|
334
|
+
expect { remote_file(server).read_io }.to raise_error(Net::HTTPClientException, '403 "Forbidden"')
|
|
335
|
+
expect(open_pipes - pipes_before).to be_empty
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
it "raises a failed metadata lookup instead of returning an empty reader" do
|
|
339
|
+
not_found = { error: "Not Found", 'http-code': 404, type: "not-found" }.to_json
|
|
340
|
+
server = serve(response("404 Not Found", { "Content-Type" => "application/json", "Content-Length" => not_found.bytesize }, not_found))
|
|
341
|
+
Files.base_url = server.url("")
|
|
342
|
+
|
|
343
|
+
expect { described_class.new(path: "report.bin").read_io }.to raise_error(Files::NotFoundError)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require_relative "../support/stubbed_api"
|
|
3
|
+
|
|
4
|
+
RSpec.describe "Saving and updating models" do
|
|
5
|
+
include StubbedApi
|
|
6
|
+
|
|
7
|
+
let(:requests) { [] }
|
|
8
|
+
|
|
9
|
+
# The API's canonical form of a workspace name.
|
|
10
|
+
def canonical_workspace(env, id)
|
|
11
|
+
name = Faraday::Utils.parse_nested_query(env.body)["name"]
|
|
12
|
+
{ id: id, name: name.strip.capitalize }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "creates a new workspace with one POST, then updates the same object in place with one PATCH each" do
|
|
16
|
+
client = stubbed_api_client do |stub|
|
|
17
|
+
stub.post("/api/rest/v1/workspaces") do |env|
|
|
18
|
+
requests << :post
|
|
19
|
+
json_response(201, canonical_workspace(env, 42))
|
|
20
|
+
end
|
|
21
|
+
stub.patch("/api/rest/v1/workspaces/42") do |env|
|
|
22
|
+
requests << :patch
|
|
23
|
+
json_response(200, canonical_workspace(env, 42))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
workspace = Files::Workspace.new({ name: " operations " }, api_key: "key-a", client: client)
|
|
27
|
+
|
|
28
|
+
expect(workspace.save).to be(true)
|
|
29
|
+
expect(workspace.attributes).to eq(id: 42, name: "Operations")
|
|
30
|
+
|
|
31
|
+
workspace.name = " finance "
|
|
32
|
+
expect(workspace.save).to be(true)
|
|
33
|
+
expect(workspace.name).to eq("Finance")
|
|
34
|
+
|
|
35
|
+
response, options = workspace.update(name: " legal ")
|
|
36
|
+
expect(response).to be_a(Files::Response)
|
|
37
|
+
expect(response.data).to eq(id: 42, name: "Legal")
|
|
38
|
+
expect(options).to include(api_key: "key-a", client: client)
|
|
39
|
+
expect(workspace.attributes).to eq(id: 42, name: "Legal")
|
|
40
|
+
expect(workspace.options).to include(api_key: "key-a", client: client)
|
|
41
|
+
expect(requests).to eq(%i[post patch patch])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "leaves the object and its options unchanged when the update fails" do
|
|
45
|
+
client = stubbed_api_client do |stub|
|
|
46
|
+
stub.patch("/api/rest/v1/workspaces/42") { json_response(404, { error: "Workspace not found", type: "not-found" }) }
|
|
47
|
+
end
|
|
48
|
+
workspace = Files::Workspace.new({ id: 42, name: "Operations" }, api_key: "key-a")
|
|
49
|
+
workspace.name = "Finance"
|
|
50
|
+
|
|
51
|
+
expect { client.request { workspace.save } }.to raise_error(Files::NotFoundError, "Workspace not found")
|
|
52
|
+
expect(workspace.attributes).to eq(id: 42, name: "Finance")
|
|
53
|
+
expect(workspace.options).to eq(api_key: "key-a")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
{ "JSON null" => "null", "a JSON list" => "[]" }.each do |description, body|
|
|
57
|
+
it "rejects #{description} as the updated workspace, leaving the object and its options unchanged" do
|
|
58
|
+
client = stubbed_api_client do |stub|
|
|
59
|
+
stub.patch("/api/rest/v1/workspaces/42") do
|
|
60
|
+
requests << :patch
|
|
61
|
+
[ 200, { "Content-Type" => "application/json" }, body ]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
workspace = Files::Workspace.new({ id: 42, name: "Operations" }, api_key: "key-a")
|
|
65
|
+
workspace.name = "Finance"
|
|
66
|
+
|
|
67
|
+
expect { client.request { workspace.save } }.to raise_error(Files::APIConnectionError) { |error|
|
|
68
|
+
expect(error).to be_an_instance_of(Files::APIConnectionError)
|
|
69
|
+
expect(error.http_body).to eq(body)
|
|
70
|
+
}
|
|
71
|
+
expect(workspace.attributes).to eq(id: 42, name: "Finance")
|
|
72
|
+
expect(workspace.options).to eq(api_key: "key-a")
|
|
73
|
+
expect(requests).to eq([ :patch ])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "rejects JSON null as the created workspace, keeping the unsaved object and its options" do
|
|
78
|
+
client = stubbed_api_client do |stub|
|
|
79
|
+
stub.post("/api/rest/v1/workspaces") do
|
|
80
|
+
requests << :post
|
|
81
|
+
[ 201, { "Content-Type" => "application/json" }, "null" ]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
workspace = Files::Workspace.new({ name: "Draft" }, api_key: "key-a")
|
|
85
|
+
|
|
86
|
+
expect { client.request { workspace.save } }.to raise_error(Files::APIConnectionError) { |error|
|
|
87
|
+
expect(error).to be_an_instance_of(Files::APIConnectionError)
|
|
88
|
+
expect(error.http_status).to eq(201)
|
|
89
|
+
}
|
|
90
|
+
expect(workspace.attributes).to eq(name: "Draft")
|
|
91
|
+
expect(workspace.options).to eq(api_key: "key-a")
|
|
92
|
+
expect(requests).to eq([ :post ])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "rejects class-level responses of the wrong shape without changing options, and accepts empty ones" do
|
|
96
|
+
json = { "Content-Type" => "application/json" }
|
|
97
|
+
client = stubbed_api_client do |stub|
|
|
98
|
+
stub.get("/api/rest/v1/workspaces/42") { [ 200, json, "null" ] }
|
|
99
|
+
stub.patch("/api/rest/v1/workspaces/42") { [ 200, json, "null" ] }
|
|
100
|
+
stub.get("/api/rest/v1/file_actions/zip_list/null.zip") { [ 200, json, "null" ] }
|
|
101
|
+
stub.post("/api/rest/v1/file_actions/begin_upload/object.csv") { [ 200, json, { part_number: 1 }.to_json ] }
|
|
102
|
+
stub.post("/api/rest/v1/file_actions/begin_upload/null-part.csv") { [ 200, json, "[null]" ] }
|
|
103
|
+
stub.get("/api/rest/v1/workspaces/7") { json_response(200, {}) }
|
|
104
|
+
stub.get("/api/rest/v1/file_actions/zip_list/empty.zip") { json_response(200, []) }
|
|
105
|
+
stub.post("/api/rest/v1/file_actions/begin_upload/empty-part.csv") { json_response(200, [ {} ]) }
|
|
106
|
+
end
|
|
107
|
+
options = { api_key: "key-a" }
|
|
108
|
+
|
|
109
|
+
client.request do
|
|
110
|
+
expect { Files::Workspace.find(42, {}, options) }.to raise_error(Files::APIConnectionError, /a Workspace object/)
|
|
111
|
+
expect { Files::Workspace.update(42, { name: "Legal" }, options) }.to raise_error(Files::APIConnectionError, /a Workspace object/)
|
|
112
|
+
expect { Files::File.zip_list_contents("null.zip", {}, options) }.to raise_error(Files::APIConnectionError, /a list of ZipListEntry objects/)
|
|
113
|
+
expect { Files::File.begin_upload("object.csv", {}, options) }.to raise_error(Files::APIConnectionError, /a list of FileUploadPart objects/)
|
|
114
|
+
expect { Files::File.begin_upload("null-part.csv", {}, options) }.to raise_error(Files::APIConnectionError, /a list of FileUploadPart objects/)
|
|
115
|
+
end
|
|
116
|
+
expect(options).to eq(api_key: "key-a")
|
|
117
|
+
|
|
118
|
+
client.request do
|
|
119
|
+
expect(Files::Workspace.find(7, {}, options).attributes).to eq({})
|
|
120
|
+
expect(Files::File.zip_list_contents("empty.zip", {}, options)).to eq([])
|
|
121
|
+
expect(Files::File.begin_upload("empty-part.csv", {}, options).map { |part| [ part.class, part.attributes ] }).to eq([ [ Files::FileUploadPart, {} ] ])
|
|
122
|
+
end
|
|
123
|
+
expect(options).to include(api_key: "key-a", client: client)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "refreshes each model from its own client, keeping false, zero, empty, null and decimal values" do
|
|
127
|
+
backend_client = stubbed_api_client do |stub|
|
|
128
|
+
stub.patch("/api/rest/v1/remote_mount_backends/7") do |env|
|
|
129
|
+
requests << [ :backend, env.request_headers["X-FilesAPI-Key"], env.request_headers["X-Files-Workspace-Id"] ]
|
|
130
|
+
json_response(200, { id: 7, enabled: false, fall: 0, status: "", canary_file_path: nil, min_free_cpu: "12.50" })
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
workspace_client = stubbed_api_client do |stub|
|
|
134
|
+
stub.patch("/api/rest/v1/workspaces/42") do |env|
|
|
135
|
+
requests << [ :workspace, env.request_headers["X-FilesAPI-Key"], env.request_headers["X-Files-Workspace-Id"] ]
|
|
136
|
+
json_response(200, canonical_workspace(env, 42))
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
backend = Files::RemoteMountBackend.new({ id: 7, enabled: true, fall: 3, status: "healthy", canary_file_path: "/health", min_free_cpu: "50" }, api_key: "key-b", workspace_id: 2, client: backend_client)
|
|
140
|
+
workspace = Files::Workspace.new({ id: 42, name: "Operations" }, api_key: "key-a", workspace_id: 1, client: workspace_client)
|
|
141
|
+
|
|
142
|
+
backend.enabled = false
|
|
143
|
+
expect(backend.save).to be(true)
|
|
144
|
+
workspace.update(name: "legal")
|
|
145
|
+
|
|
146
|
+
expect([ backend.enabled, backend.fall, backend.status, backend.canary_file_path ]).to eq([ false, 0, "", nil ])
|
|
147
|
+
expect(backend.min_free_cpu).to eq(BigDecimal("12.5"))
|
|
148
|
+
expect(backend.options).to include(api_key: "key-b", workspace_id: 2, client: backend_client)
|
|
149
|
+
expect(workspace.name).to eq("Legal")
|
|
150
|
+
expect(workspace.options).to include(api_key: "key-a", workspace_id: 1, client: workspace_client)
|
|
151
|
+
expect(requests).to eq([ [ :backend, "key-b", "2" ], [ :workspace, "key-a", "1" ] ])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "saves a model that can only be updated with one PATCH to its path" do
|
|
155
|
+
client = stubbed_api_client do |stub|
|
|
156
|
+
stub.patch("/api/rest/v1/styles/team/reports") do
|
|
157
|
+
requests << :patch
|
|
158
|
+
json_response(200, { id: 5, path: "team/reports", logo_click_href: "https://example.test/" })
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
style = Files::Style.new({ path: "team/reports", logo_click_href: "https://example.test" }, api_key: "key-a", client: client)
|
|
162
|
+
|
|
163
|
+
expect(style.save).to be(true)
|
|
164
|
+
expect(style.attributes).to eq(id: 5, path: "team/reports", logo_click_href: "https://example.test/")
|
|
165
|
+
expect(requests).to eq([ :patch ])
|
|
166
|
+
end
|
|
167
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,5 +1,102 @@
|
|
|
1
1
|
require "files.com"
|
|
2
|
+
require "json"
|
|
3
|
+
require "net/http"
|
|
2
4
|
require "pathname"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
# Examples tagged :with_test_folder send the generated SDK's real HTTP requests to the Files.com mock
|
|
8
|
+
# server's simulator, never to Files.com. FILES_MOCK_SERVER_HOST and FILES_MOCK_SERVER_PORT name its
|
|
9
|
+
# loopback address, and FILES_MOCK_SCHEMA_SHA256 the schema its readiness report must name (the
|
|
10
|
+
# schema_sha256 in its lib/simulation/generation.json). When one is missing, or the server there is not
|
|
11
|
+
# that simulator, the example fails before the SDK sends anything.
|
|
12
|
+
module MockServerFixture
|
|
13
|
+
LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
|
|
14
|
+
READY_TIMEOUT = 30
|
|
15
|
+
# Not a real API key: the simulator accepts any key and journals it only by number.
|
|
16
|
+
API_KEY = "synthetic-mock-server-api-key".freeze
|
|
17
|
+
PARENT = Pathname.new("files_regional_worker")
|
|
18
|
+
|
|
19
|
+
def self.origin
|
|
20
|
+
values = %w[FILES_MOCK_SERVER_HOST FILES_MOCK_SERVER_PORT FILES_MOCK_SCHEMA_SHA256].to_h { |name| [ name, ENV[name].to_s.strip ] }
|
|
21
|
+
missing = values.select { |_, value| value.empty? }.keys
|
|
22
|
+
raise "Set #{missing.join(", ")} for the Files.com mock server's simulator; these examples never use Files.com." if missing.any?
|
|
23
|
+
|
|
24
|
+
host, port, schema = values.values
|
|
25
|
+
raise "FILES_MOCK_SERVER_HOST must be a loopback address (#{LOOPBACK_HOSTS.join(", ")}), not #{host.inspect}." unless LOOPBACK_HOSTS.include?(host)
|
|
26
|
+
raise "FILES_MOCK_SERVER_PORT must be a port number, not #{port.inspect}." unless port.match?(/\A\d{1,5}\z/) && port.to_i.between?(1, 65_535)
|
|
27
|
+
|
|
28
|
+
origin = "http://#{host == "::1" ? "[::1]" : host}:#{port}"
|
|
29
|
+
expected = { "status" => "ready", "mode" => "simulation", "contract_version" => 3, "schema_sha256" => schema }
|
|
30
|
+
reported = ready(origin).slice(*expected.keys)
|
|
31
|
+
raise "#{origin} is not the expected simulator: it reports #{reported}, not #{expected}." unless reported == expected
|
|
32
|
+
|
|
33
|
+
origin
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Waits up to READY_TIMEOUT for something to listen at origin. Whatever answers first decides: a
|
|
37
|
+
# legacy mock server or any other server has no readiness report.
|
|
38
|
+
def self.ready(origin)
|
|
39
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + READY_TIMEOUT
|
|
40
|
+
begin
|
|
41
|
+
response = control(origin, "ready")
|
|
42
|
+
rescue SystemCallError, Net::OpenTimeout
|
|
43
|
+
raise "Nothing answered at #{origin} within #{READY_TIMEOUT} seconds." if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
44
|
+
|
|
45
|
+
sleep 0.2
|
|
46
|
+
retry
|
|
47
|
+
end
|
|
48
|
+
raise "#{origin} answered #{response.code} for its readiness report, so it is not the simulator." unless response.code == "200"
|
|
49
|
+
|
|
50
|
+
JSON.parse(response.body)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
raise "#{origin} answered its readiness report with something other than JSON, so it is not the simulator."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.control(origin, name)
|
|
56
|
+
uri = URI("#{origin}/__files_mock/v1/#{name}")
|
|
57
|
+
Net::HTTP.start(uri.hostname, uri.port, open_timeout: 5, read_timeout: 10) { |http| http.get(uri.request_uri) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.journal(origin)
|
|
61
|
+
journal = JSON.parse(control(origin, "journal").body)
|
|
62
|
+
raise "The simulator's journal is incomplete, so it cannot show which requests the SDK sent." unless journal["complete"] == true
|
|
63
|
+
|
|
64
|
+
journal.fetch("entries")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The API requests the example sent: each carried the same synthetic API key (the journal numbers keys
|
|
68
|
+
# and never records them), no session, and the Ruby SDK's User-Agent. Transfers to the upload and
|
|
69
|
+
# download URLs the simulator issued carry no credential, as presigned URLs do not.
|
|
70
|
+
def self.check_requests(origin, after)
|
|
71
|
+
entries = journal(origin).select { |entry| entry.fetch("seq") > after && entry.fetch("path").start_with?("/api/rest/v1/") }
|
|
72
|
+
raise "No API request reached the simulator." if entries.empty?
|
|
73
|
+
|
|
74
|
+
keys = entries.map { |entry| entry.dig("credentials", "api_key") }.uniq
|
|
75
|
+
raise "The SDK's requests carried API keys #{keys.inspect}, not one key on every request." unless keys.size == 1 && keys.first.is_a?(Integer)
|
|
76
|
+
raise "An SDK request carried a session." if entries.any? { |entry| entry.dig("credentials", "session") }
|
|
77
|
+
|
|
78
|
+
agents = entries.map { |entry| entry["user_agent"] }.uniq
|
|
79
|
+
raise "Requests came with User-Agents #{agents.inspect}, not only the Ruby SDK's." unless agents.all? { |agent| agent.to_s.start_with?("Files.com Ruby SDK v") }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Deletes the example's folder with everything in it, then the sibling made beside it, checking after
|
|
83
|
+
# each delete that only that folder went: the parent stays, and the sibling, whose name begins with the
|
|
84
|
+
# example folder's, stays until its own delete.
|
|
85
|
+
def self.remove(created, test_folder, sibling, options)
|
|
86
|
+
if created.include?(test_folder)
|
|
87
|
+
Files::Folder.delete(test_folder.to_s, { recursive: true }, options)
|
|
88
|
+
left = [ test_folder, test_folder.join("nested") ].select { |folder| Files::Folder.exist?(folder.to_s, options) }
|
|
89
|
+
raise "Deleting #{test_folder} left #{left.join(", ")}." if left.any?
|
|
90
|
+
end
|
|
91
|
+
lost = [ PARENT, (sibling if created.include?(sibling)) ].compact.reject { |folder| Files::Folder.exist?(folder.to_s, options) }
|
|
92
|
+
raise "Deleting #{test_folder} also removed #{lost.join(", ")}." if lost.any?
|
|
93
|
+
return unless created.include?(sibling)
|
|
94
|
+
|
|
95
|
+
Files::Folder.delete(sibling.to_s, {}, options)
|
|
96
|
+
raise "Deleting #{sibling} left it." if Files::Folder.exist?(sibling.to_s, options)
|
|
97
|
+
raise "Deleting #{sibling} also removed #{PARENT}." unless Files::Folder.exist?(PARENT.to_s, options)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
3
100
|
|
|
4
101
|
RSpec.configure do |config|
|
|
5
102
|
config.expect_with :rspec do |expectations|
|
|
@@ -19,14 +116,33 @@ RSpec.configure do |config|
|
|
|
19
116
|
config.include_context "API Helpers", :with_test_folder
|
|
20
117
|
|
|
21
118
|
config.around(:example, :with_test_folder) do |all|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Files
|
|
26
|
-
|
|
119
|
+
base_url = Files.base_url
|
|
120
|
+
language = Files.language
|
|
121
|
+
origin = MockServerFixture.origin
|
|
122
|
+
Files.base_url = origin
|
|
123
|
+
after = MockServerFixture.journal(origin).map { |entry| entry.fetch("seq") }.max || 0
|
|
124
|
+
@api_key = MockServerFixture::API_KEY
|
|
125
|
+
@test_folder = MockServerFixture::PARENT.join(SecureRandom.uuid)
|
|
126
|
+
sibling = MockServerFixture::PARENT.join("#{@test_folder.basename}-sibling")
|
|
127
|
+
created = []
|
|
128
|
+
Files::Folder.mkdir(MockServerFixture::PARENT.to_s, {}, api_key: @api_key) unless Files::Folder.exist?(MockServerFixture::PARENT.to_s, api_key: @api_key)
|
|
129
|
+
[ sibling, @test_folder, @test_folder.join("nested") ].each do |folder|
|
|
130
|
+
Files::Folder.mkdir(folder.to_s, {}, api_key: @api_key)
|
|
131
|
+
created << folder
|
|
132
|
+
end
|
|
27
133
|
all.run
|
|
28
134
|
ensure
|
|
29
|
-
|
|
135
|
+
begin
|
|
136
|
+
if created&.any?
|
|
137
|
+
MockServerFixture.remove(created, @test_folder, sibling, { api_key: @api_key })
|
|
138
|
+
MockServerFixture.check_requests(origin, after)
|
|
139
|
+
end
|
|
140
|
+
ensure
|
|
141
|
+
Files.base_url = base_url
|
|
142
|
+
left_language = Files.language
|
|
143
|
+
Files.language = language
|
|
144
|
+
end
|
|
145
|
+
raise "The example left Files.language set to #{left_language.inspect}." unless left_language == language
|
|
30
146
|
end
|
|
31
147
|
|
|
32
148
|
config.shared_context_metadata_behavior = :apply_to_host_groups
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Specs that include this call the Files.com API through the SDK's own
|
|
2
|
+
# connection, with only the network replaced by Faraday test stubs, so requests,
|
|
3
|
+
# responses and errors take the real SDK path.
|
|
4
|
+
module StubbedApi
|
|
5
|
+
def stubbed_api_client(&routes)
|
|
6
|
+
connection = Files::ApiClient.build_default_conn
|
|
7
|
+
connection.adapter :test, Faraday::Adapter::Test::Stubs.new(&routes)
|
|
8
|
+
Files::ApiClient.new(connection)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def json_response(status, body, headers = {})
|
|
12
|
+
[ status, { "Content-Type" => "application/json" }.merge(headers), body.to_json ]
|
|
13
|
+
end
|
|
14
|
+
end
|