files.com 1.1.795 → 1.1.796
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/_VERSION +1 -1
- data/lib/files.com/models/file.rb +42 -10
- data/spec/api_scenarios_spec.rb +1298 -0
- data/spec/models/file_transfer_spec.rb +191 -0
- data/spec/models/file_underscore_destinations_spec.rb +1 -1
- data/spec/spec_helper.rb +118 -8
- metadata +2 -1
|
@@ -2,9 +2,12 @@ require "spec_helper"
|
|
|
2
2
|
require "stringio"
|
|
3
3
|
require "tmpdir"
|
|
4
4
|
require "zlib"
|
|
5
|
+
require_relative "../support/stubbed_api"
|
|
5
6
|
require_relative "../support/transfer_server"
|
|
6
7
|
|
|
7
8
|
RSpec.describe Files::File, "transfers" do
|
|
9
|
+
include StubbedApi
|
|
10
|
+
|
|
8
11
|
# 22,528 bytes that include every byte value, so text handling would show.
|
|
9
12
|
let(:payload) { (0..255).map(&:chr).join.b * 88 }
|
|
10
13
|
let(:signed_target) { "/objects/report.bin?X-Amz-Credential=fixture%2Fcredential&X-Amz-Signature=fixture-signature" }
|
|
@@ -53,6 +56,118 @@ RSpec.describe Files::File, "transfers" do
|
|
|
53
56
|
complete ? "#{encoded}0\r\n\r\n" : encoded
|
|
54
57
|
end
|
|
55
58
|
|
|
59
|
+
# The API and storage an upload sends through: the SDK's own connection with Faraday test stubs. Each begin_upload of
|
|
60
|
+
# report.bin grants the parts it asks for, partsize bytes each, at https://storage.example.test/parts/<number>; each
|
|
61
|
+
# part request is answered by part_response (200 with an ETag unless given), and the end of the upload with 201.
|
|
62
|
+
# requests keeps every request in order, with its parameters or body and its headers. A planning request past the
|
|
63
|
+
# tenth fails, so an upload that never ends stops.
|
|
64
|
+
def upload_api(requests, partsize:, part_response: nil)
|
|
65
|
+
stubbed_api_client do |stub|
|
|
66
|
+
stub.post("/api/rest/v1/file_actions/begin_upload/report.bin") do |env|
|
|
67
|
+
params = Faraday::Utils.parse_nested_query(env.body)
|
|
68
|
+
requests << [ :begin, params, env.request_headers ]
|
|
69
|
+
raise "The upload planned more than ten parts" if requests.count { |kind, *| kind == :begin } > 10
|
|
70
|
+
|
|
71
|
+
first = Integer(params.fetch("part"))
|
|
72
|
+
json_response(200, (first...(first + Integer(params.fetch("parts")))).map do |number|
|
|
73
|
+
{ part_number: number, partsize: partsize, ref: "upload-ref", http_method: "PUT", upload_uri: "https://storage.example.test/parts/#{number}" }
|
|
74
|
+
end
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
stub.put(/\A\/parts\/\d+\z/) do |env|
|
|
78
|
+
number = Integer(env.url.path.split("/").last)
|
|
79
|
+
requests << [ :part, number, env.body, env.request_headers ]
|
|
80
|
+
part_response ? part_response.call(number) : [ 200, { "ETag" => "\"etag-#{number}\"" }, "" ]
|
|
81
|
+
end
|
|
82
|
+
stub.post("/api/rest/v1/files/report.bin") do |env|
|
|
83
|
+
requests << [ :end, Faraday::Utils.parse_nested_query(env.body), env.request_headers ]
|
|
84
|
+
json_response(201, { path: "report.bin", type: "file" })
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parts_sent(requests)
|
|
90
|
+
requests.select { |kind, *| kind == :part }.map { |_, number, body| [ number, body ] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A source that has only read, eof? and close: it gives its data at most max_read bytes a read, then, at its end,
|
|
94
|
+
# end_read (nil or an empty string). From stall_at on, every read gives an empty string although eof? is false. It
|
|
95
|
+
# refuses its hundred-and-first read, so a loop that never ends stops.
|
|
96
|
+
def short_reader(data, max_read:, end_read: nil, stall_at: nil)
|
|
97
|
+
offset = 0
|
|
98
|
+
reads = 0
|
|
99
|
+
Object.new.tap do |reader|
|
|
100
|
+
reader.define_singleton_method(:read) do |length = nil, _outbuf = nil|
|
|
101
|
+
raise "The source was read more than a hundred times" if (reads += 1) > 100
|
|
102
|
+
next "" if stall_at && offset >= stall_at
|
|
103
|
+
next end_read if offset >= data.bytesize
|
|
104
|
+
|
|
105
|
+
data.byteslice(offset, [ max_read, length || max_read ].min).tap { |chunk| offset += chunk.bytesize }
|
|
106
|
+
end
|
|
107
|
+
reader.define_singleton_method(:eof?) { offset >= data.bytesize }
|
|
108
|
+
reader.define_singleton_method(:close) { nil }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Runs block with the path of a new local file holding data.
|
|
113
|
+
def with_local_file(data)
|
|
114
|
+
Dir.mktmpdir do |dir|
|
|
115
|
+
path = File.join(dir, "report.bin")
|
|
116
|
+
File.binwrite(path, data)
|
|
117
|
+
yield path
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
describe ".upload_chunks" do
|
|
122
|
+
it "fills each granted part from as many short reads as it takes" do
|
|
123
|
+
requests = []
|
|
124
|
+
described_class.upload_chunks(short_reader("abcdefghij", max_read: 3), "report.bin", { client: upload_api(requests, partsize: 4) })
|
|
125
|
+
|
|
126
|
+
expect(parts_sent(requests)).to eq([ [ 1, "abcd" ], [ 2, "efgh" ], [ 3, "ij" ] ])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "ends a part at the source's end when its last read gives an empty string" do
|
|
130
|
+
requests = []
|
|
131
|
+
described_class.upload_chunks(short_reader("abcdefghij", max_read: 4, end_read: ""), "report.bin", { client: upload_api(requests, partsize: 4) })
|
|
132
|
+
|
|
133
|
+
expect(parts_sent(requests)).to eq([ [ 1, "abcd" ], [ 2, "efgh" ], [ 3, "ij" ] ])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "refuses a source that gives nothing before its end, before sending that part" do
|
|
137
|
+
requests = []
|
|
138
|
+
reader = short_reader("abcdefghij", max_read: 4, stall_at: 4)
|
|
139
|
+
|
|
140
|
+
expect { described_class.upload_chunks(reader, "report.bin", { client: upload_api(requests, partsize: 4) }) }
|
|
141
|
+
.to raise_error(Files::InvalidParameterError, "The upload source returned no bytes before its end")
|
|
142
|
+
expect(parts_sent(requests)).to eq([ [ 1, "abcd" ] ])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "lets a read's own error through, sending nothing after it" do
|
|
146
|
+
requests = []
|
|
147
|
+
read_error = IOError.new("the source failed")
|
|
148
|
+
reader = short_reader("abcdefghij", max_read: 4)
|
|
149
|
+
first_read = reader.method(:read)
|
|
150
|
+
reads = 0
|
|
151
|
+
reader.define_singleton_method(:read) { |*args| (reads += 1) == 2 ? raise(read_error) : first_read.call(*args) }
|
|
152
|
+
|
|
153
|
+
expect { described_class.upload_chunks(reader, "report.bin", { client: upload_api(requests, partsize: 4) }) }.to raise_error(IOError) { |error| expect(error).to equal(read_error) }
|
|
154
|
+
expect(parts_sent(requests)).to eq([ [ 1, "abcd" ] ])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "reports each acknowledged part to progress, with an unknown total and this call's acknowledged bytes" do
|
|
158
|
+
requests = []
|
|
159
|
+
reports = []
|
|
160
|
+
described_class.upload_chunks(short_reader("abcdefghij", max_read: 3), "report.bin", { client: upload_api(requests, partsize: 4) }, progress: ->(report) { reports << report })
|
|
161
|
+
|
|
162
|
+
expect(reports).to eq([
|
|
163
|
+
{ total_bytes: nil, acknowledged_bytes: 4, part_number: 1, part_bytes: 4 },
|
|
164
|
+
{ total_bytes: nil, acknowledged_bytes: 8, part_number: 2, part_bytes: 4 },
|
|
165
|
+
{ total_bytes: nil, acknowledged_bytes: 10, part_number: 3, part_bytes: 2 }
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
56
171
|
describe ".upload_file" do
|
|
57
172
|
it "raises the filesystem error when the local source does not exist" do
|
|
58
173
|
Dir.mktmpdir do |dir|
|
|
@@ -61,6 +176,82 @@ RSpec.describe Files::File, "transfers" do
|
|
|
61
176
|
expect { described_class.upload_file(missing_path, "report.bin") }.to raise_error(Errno::ENOENT)
|
|
62
177
|
end
|
|
63
178
|
end
|
|
179
|
+
|
|
180
|
+
it "uploads an empty file as one empty part" do
|
|
181
|
+
requests = []
|
|
182
|
+
with_local_file("") { |path| described_class.upload_file(path, "report.bin", { client: upload_api(requests, partsize: 4) }) }
|
|
183
|
+
|
|
184
|
+
expect(parts_sent(requests)).to eq([ [ 1, "" ] ])
|
|
185
|
+
expect(requests.last[0..1]).to match([ :end, hash_including("action" => "end", "size" => "0") ])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "reports progress with the local file's size as its total, whatever size its planning parameters carry, and sends progress in no request" do
|
|
189
|
+
requests = []
|
|
190
|
+
reports = []
|
|
191
|
+
with_local_file("abcdefghij") do |path|
|
|
192
|
+
described_class.upload_file(path, "report.bin", { client: upload_api(requests, partsize: 4) }, params: { size: 999 }, progress: ->(report) { reports << report })
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
expect(requests.first[1]["size"]).to eq("999")
|
|
196
|
+
expect(reports).to eq([
|
|
197
|
+
{ total_bytes: 10, acknowledged_bytes: 4, part_number: 1, part_bytes: 4 },
|
|
198
|
+
{ total_bytes: 10, acknowledged_bytes: 8, part_number: 2, part_bytes: 4 },
|
|
199
|
+
{ total_bytes: 10, acknowledged_bytes: 10, part_number: 3, part_bytes: 2 }
|
|
200
|
+
]
|
|
201
|
+
)
|
|
202
|
+
requests.each do |_kind, sent, *, headers|
|
|
203
|
+
expect(headers.keys.map(&:downcase)).not_to include("progress")
|
|
204
|
+
expect(sent.keys).not_to include("progress") if sent.is_a?(Hash)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "refuses a part answered with a non-success status it returns, before reporting or ending the upload, when progress is given" do
|
|
209
|
+
requests = []
|
|
210
|
+
reports = []
|
|
211
|
+
client = upload_api(requests, partsize: 4, part_response: ->(_number) { [ 302, {}, "" ] })
|
|
212
|
+
|
|
213
|
+
with_local_file("abcdefghij") do |path|
|
|
214
|
+
expect { described_class.upload_file(path, "report.bin", { client: client }, progress: ->(report) { reports << report }) }
|
|
215
|
+
.to raise_error(Files::APIError, "Transfer request failed") { |error|
|
|
216
|
+
expect(error.http_status).to eq(302)
|
|
217
|
+
expect(error.full_message).not_to include("storage.example.test")
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
expect(reports).to be_empty
|
|
221
|
+
expect(requests.map(&:first)).to eq(%i[begin part])
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it "uploads as before when a part's non-success status is returned and no progress is given" do
|
|
225
|
+
requests = []
|
|
226
|
+
client = upload_api(requests, partsize: 4, part_response: ->(_number) { [ 302, {}, "" ] })
|
|
227
|
+
|
|
228
|
+
with_local_file("abcdefghij") { |path| described_class.upload_file(path, "report.bin", { client: client }) }
|
|
229
|
+
|
|
230
|
+
expect(requests.map(&:first)).to eq(%i[begin part begin part begin part end])
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "stops at a part answered 500, reporting nothing and not ending the upload" do
|
|
234
|
+
requests = []
|
|
235
|
+
reports = []
|
|
236
|
+
client = upload_api(requests, partsize: 4, part_response: ->(_number) { [ 500, {}, "" ] })
|
|
237
|
+
|
|
238
|
+
with_local_file("abcdefghij") do |path|
|
|
239
|
+
expect { described_class.upload_file(path, "report.bin", { client: client }, progress: ->(report) { reports << report }) }.to raise_error(Files::APIConnectionError)
|
|
240
|
+
end
|
|
241
|
+
expect(reports).to be_empty
|
|
242
|
+
expect(requests.map(&:first)).to eq(%i[begin part])
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
it "ends the upload where its progress callback raises, with nothing planned, sent or ended after it, and closes its own file" do
|
|
246
|
+
requests = []
|
|
247
|
+
cancelled = StandardError.new("cancelled")
|
|
248
|
+
with_local_file("abcdefghij") do |path|
|
|
249
|
+
expect { described_class.upload_file(path, "report.bin", { client: upload_api(requests, partsize: 4) }, progress: ->(_report) { raise cancelled }) }
|
|
250
|
+
.to raise_error(StandardError) { |error| expect(error).to equal(cancelled) }
|
|
251
|
+
expect(ObjectSpace.each_object(::File).select { |file| !file.closed? && file.path == path }).to be_empty
|
|
252
|
+
end
|
|
253
|
+
expect(requests.map(&:first)).to eq(%i[begin part])
|
|
254
|
+
end
|
|
64
255
|
end
|
|
65
256
|
|
|
66
257
|
describe "#download_content" do
|
data/spec/spec_helper.rb
CHANGED
|
@@ -16,7 +16,15 @@ module MockServerFixture
|
|
|
16
16
|
API_KEY = "synthetic-mock-server-api-key".freeze
|
|
17
17
|
PARENT = Pathname.new("files_regional_worker")
|
|
18
18
|
|
|
19
|
+
# The shared API scenarios contract an example names with its :api_scenarios metadata, the definition's SHA-256.
|
|
20
|
+
API_SCENARIOS = { "format" => "files-api-scenarios", "format_version" => 1 }.freeze
|
|
21
|
+
|
|
19
22
|
def self.origin
|
|
23
|
+
simulator.first
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The simulator's origin and the readiness report that showed it is the expected simulator.
|
|
27
|
+
def self.simulator
|
|
20
28
|
values = %w[FILES_MOCK_SERVER_HOST FILES_MOCK_SERVER_PORT FILES_MOCK_SCHEMA_SHA256].to_h { |name| [ name, ENV[name].to_s.strip ] }
|
|
21
29
|
missing = values.select { |_, value| value.empty? }.keys
|
|
22
30
|
raise "Set #{missing.join(", ")} for the Files.com mock server's simulator; these examples never use Files.com." if missing.any?
|
|
@@ -26,11 +34,95 @@ module MockServerFixture
|
|
|
26
34
|
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
35
|
|
|
28
36
|
origin = "http://#{host == "::1" ? "[::1]" : host}:#{port}"
|
|
37
|
+
report = ready(origin)
|
|
38
|
+
expect_simulator(origin, report, schema)
|
|
39
|
+
[ origin, report ]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.expect_simulator(origin, report, schema = ENV["FILES_MOCK_SCHEMA_SHA256"].to_s.strip)
|
|
29
43
|
expected = { "status" => "ready", "mode" => "simulation", "contract_version" => 3, "schema_sha256" => schema }
|
|
30
|
-
reported =
|
|
44
|
+
reported = report.slice(*expected.keys)
|
|
31
45
|
raise "#{origin} is not the expected simulator: it reports #{reported}, not #{expected}." unless reported == expected
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# This run's hold on the shared daemon. @daemon is the origin and instance the run admitted first. @owned is the
|
|
49
|
+
# epoch this run made, { origin:, instance:, epoch:, mode:, identity: } (mode :default and identity nil after {}),
|
|
50
|
+
# set only once the reset's own answer and a fresh readiness report both showed it. @failed is the first reset that
|
|
51
|
+
# failed or could not be verified: once it is set, nothing resets the daemon again and the suite fails. The runtime
|
|
52
|
+
# must give the run a fresh daemon of its own: epoch 0 shows the boot profile, not that the daemon is empty or that
|
|
53
|
+
# nothing else uses it.
|
|
54
|
+
#
|
|
55
|
+
# Admits an example at the daemon `report` came from, before its journal baseline or any folder, and returns the
|
|
56
|
+
# contract its journal checks join, or nil. An example naming the API scenarios contract (definition_sha256) reuses
|
|
57
|
+
# an epoch this run made under that identity, or else resets the daemon into it, only on the instance first admitted
|
|
58
|
+
# and only from the boot epoch or a default epoch this run made. Any other example runs as before and sends nothing
|
|
59
|
+
# more, unless the daemon serves the contract: an epoch this run made is first returned to the default, and any
|
|
60
|
+
# other is refused. Nothing is reset for a refused example.
|
|
61
|
+
def self.admit(origin, report, definition_sha256)
|
|
62
|
+
# Once a reset failed or could not be verified, no example is admitted: nothing resets the daemon or makes folders.
|
|
63
|
+
raise_failed_transition
|
|
64
|
+
instance, epoch, active = report["instance"], report["epoch"], report.dig("profile", "api_scenarios")
|
|
65
|
+
@daemon ||= [ origin, instance ]
|
|
66
|
+
replaced = @daemon != [ origin, instance ]
|
|
67
|
+
@owned = nil if replaced || (@owned && [ @owned[:epoch], @owned[:identity] ] != [ epoch, active ])
|
|
32
68
|
|
|
33
|
-
|
|
69
|
+
if definition_sha256
|
|
70
|
+
raise "#{origin} now answers as instance #{instance.inspect}, not #{@daemon.last.inspect}, the daemon this run admitted first; nothing was reset." if replaced
|
|
71
|
+
|
|
72
|
+
wanted = API_SCENARIOS.merge("definition_sha256" => definition_sha256)
|
|
73
|
+
return @owned if @owned && @owned[:identity] == wanted
|
|
74
|
+
|
|
75
|
+
raise "#{origin} is at epoch #{epoch.inspect} serving #{active.inspect}, which this run did not make; the API scenarios examples reset only the boot epoch or a default epoch this run made." unless (epoch == 0 && active.nil? && @owned.nil?) || (@owned && @owned[:mode] == :default)
|
|
76
|
+
|
|
77
|
+
transition(origin, instance, epoch, { "profile" => { "api_scenarios" => wanted } }, wanted)
|
|
78
|
+
elsif active
|
|
79
|
+
raise "#{origin} serves the API scenarios contract #{active.inspect} at epoch #{epoch.inspect}, which this run did not select; this example needs the default profile." unless @owned
|
|
80
|
+
|
|
81
|
+
transition(origin, instance, epoch, {}, nil)
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Resets the daemon from `epoch` with `body` and verifies the next epoch on the same instance: first in the reset's
|
|
87
|
+
# own answer ({} is answered with the epoch alone; the contract adds the instance and its identity), then in a fresh
|
|
88
|
+
# readiness report. {} chooses the boot-default profile and an empty state; it restores nothing that came before. A
|
|
89
|
+
# reset attempted grants nothing: @owned is set only once both checks pass, and the first failure latches.
|
|
90
|
+
def self.transition(origin, instance, epoch, body, identity)
|
|
91
|
+
@owned = nil
|
|
92
|
+
response = reset(origin, body)
|
|
93
|
+
answer = JSON.parse(response.body)
|
|
94
|
+
expected = { "epoch" => epoch + 1 }.merge(identity ? { "instance" => instance, "api_scenarios" => identity } : {})
|
|
95
|
+
raise "the reset answered #{response.code} #{answer.inspect}, not 200 #{expected}" unless response.code == "200" && answer == expected
|
|
96
|
+
|
|
97
|
+
report = ready(origin)
|
|
98
|
+
expect_simulator(origin, report)
|
|
99
|
+
shown = [ report["instance"], report["epoch"], report.dig("profile", "api_scenarios") ]
|
|
100
|
+
raise "readiness after the reset shows instance, epoch and api_scenarios #{shown.inspect}, not #{[ instance, epoch + 1, identity ].inspect}" unless shown == [ instance, epoch + 1, identity ]
|
|
101
|
+
|
|
102
|
+
@owned = { origin: origin, instance: instance, epoch: epoch + 1, mode: identity ? :api_scenarios : :default, identity: identity }
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
@failed ||= "resetting #{origin} from epoch #{epoch.inspect} with #{JSON.generate(body)}: #{e.message}"
|
|
105
|
+
raise_failed_transition
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.raise_failed_transition
|
|
109
|
+
raise "The shared daemon's API scenarios contract could not be entered or left (#{@failed}); this run resets it no further." if @failed
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# After the suite: a failed transition fails the suite again, before anything else. Only an epoch this run made under
|
|
113
|
+
# the API scenarios contract is then returned to the default, once readiness still shows exactly that epoch; a run
|
|
114
|
+
# that made none sends nothing.
|
|
115
|
+
def self.finish
|
|
116
|
+
raise_failed_transition
|
|
117
|
+
return unless @owned && @owned[:mode] == :api_scenarios
|
|
118
|
+
|
|
119
|
+
origin, instance, epoch, identity = @owned.values_at(:origin, :instance, :epoch, :identity)
|
|
120
|
+
report = ready(origin)
|
|
121
|
+
expect_simulator(origin, report)
|
|
122
|
+
shown = [ report["instance"], report["epoch"], report.dig("profile", "api_scenarios") ]
|
|
123
|
+
raise "#{origin} now shows instance, epoch and api_scenarios #{shown.inspect}, not #{[ instance, epoch, identity ].inspect}, the epoch this run made; it is left as it is." unless shown == [ instance, epoch, identity ]
|
|
124
|
+
|
|
125
|
+
transition(origin, instance, epoch, {}, nil)
|
|
34
126
|
end
|
|
35
127
|
|
|
36
128
|
# Waits up to READY_TIMEOUT for something to listen at origin. Whatever answers first decides: a
|
|
@@ -57,18 +149,31 @@ module MockServerFixture
|
|
|
57
149
|
Net::HTTP.start(uri.hostname, uri.port, open_timeout: 5, read_timeout: 10) { |http| http.get(uri.request_uri) }
|
|
58
150
|
end
|
|
59
151
|
|
|
60
|
-
def self.
|
|
152
|
+
def self.reset(origin, body)
|
|
153
|
+
uri = URI("#{origin}/__files_mock/v1/reset")
|
|
154
|
+
Net::HTTP.start(uri.hostname, uri.port, open_timeout: 5, read_timeout: 10) { |http| http.post(uri.request_uri, JSON.generate(body), "Content-Type" => "application/json") }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The journal's entries. Under the API scenarios contract the journal must also show the contract's origin, epoch,
|
|
158
|
+
# instance and identity, and that it dropped nothing, so its entries are the ones that epoch served.
|
|
159
|
+
def self.journal(origin, contract = nil)
|
|
61
160
|
journal = JSON.parse(control(origin, "journal").body)
|
|
62
161
|
raise "The simulator's journal is incomplete, so it cannot show which requests the SDK sent." unless journal["complete"] == true
|
|
63
162
|
|
|
163
|
+
if contract
|
|
164
|
+
shown = [ origin, *journal.values_at("epoch", "instance", "api_scenarios", "dropped") ]
|
|
165
|
+
expected = [ contract[:origin], contract[:epoch], contract[:instance], contract[:identity], 0 ]
|
|
166
|
+
raise "The simulator's journal shows origin, epoch, instance, api_scenarios and dropped #{shown.inspect}, not this example's #{expected.inspect}." unless shown == expected
|
|
167
|
+
end
|
|
168
|
+
|
|
64
169
|
journal.fetch("entries")
|
|
65
170
|
end
|
|
66
171
|
|
|
67
172
|
# The API requests the example sent: each carried the same synthetic API key (the journal numbers keys
|
|
68
173
|
# and never records them), no session, and the Ruby SDK's User-Agent. Transfers to the upload and
|
|
69
174
|
# 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/") }
|
|
175
|
+
def self.check_requests(origin, after, contract = nil)
|
|
176
|
+
entries = journal(origin, contract).select { |entry| entry.fetch("seq") > after && entry.fetch("path").start_with?("/api/rest/v1/") }
|
|
72
177
|
raise "No API request reached the simulator." if entries.empty?
|
|
73
178
|
|
|
74
179
|
keys = entries.map { |entry| entry.dig("credentials", "api_key") }.uniq
|
|
@@ -118,9 +223,10 @@ RSpec.configure do |config|
|
|
|
118
223
|
config.around(:example, :with_test_folder) do |all|
|
|
119
224
|
base_url = Files.base_url
|
|
120
225
|
language = Files.language
|
|
121
|
-
origin = MockServerFixture.
|
|
226
|
+
origin, readiness = MockServerFixture.simulator
|
|
227
|
+
contract = MockServerFixture.admit(origin, readiness, all.metadata[:api_scenarios])
|
|
122
228
|
Files.base_url = origin
|
|
123
|
-
after = MockServerFixture.journal(origin).map { |entry| entry.fetch("seq") }.max || 0
|
|
229
|
+
after = MockServerFixture.journal(origin, contract).map { |entry| entry.fetch("seq") }.max || 0
|
|
124
230
|
@api_key = MockServerFixture::API_KEY
|
|
125
231
|
@test_folder = MockServerFixture::PARENT.join(SecureRandom.uuid)
|
|
126
232
|
sibling = MockServerFixture::PARENT.join("#{@test_folder.basename}-sibling")
|
|
@@ -135,7 +241,7 @@ RSpec.configure do |config|
|
|
|
135
241
|
begin
|
|
136
242
|
if created&.any?
|
|
137
243
|
MockServerFixture.remove(created, @test_folder, sibling, { api_key: @api_key })
|
|
138
|
-
MockServerFixture.check_requests(origin, after)
|
|
244
|
+
MockServerFixture.check_requests(origin, after, contract)
|
|
139
245
|
end
|
|
140
246
|
ensure
|
|
141
247
|
Files.base_url = base_url
|
|
@@ -145,6 +251,10 @@ RSpec.configure do |config|
|
|
|
145
251
|
raise "The example left Files.language set to #{left_language.inspect}." unless left_language == language
|
|
146
252
|
end
|
|
147
253
|
|
|
254
|
+
# A failed transition fails the suite before anything else; only an epoch this run made under the API scenarios
|
|
255
|
+
# contract is then returned to the default. A run that made none sends nothing.
|
|
256
|
+
config.after(:suite) { MockServerFixture.finish }
|
|
257
|
+
|
|
148
258
|
config.shared_context_metadata_behavior = :apply_to_host_groups
|
|
149
259
|
config.fail_if_no_examples = true
|
|
150
260
|
config.disable_monkey_patching!
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: files.com
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.796
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- files.com
|
|
@@ -483,6 +483,7 @@ files:
|
|
|
483
483
|
- shared/path_comparison.json
|
|
484
484
|
- shared/path_comparison.md
|
|
485
485
|
- shared/url_test_data.json
|
|
486
|
+
- spec/api_scenarios_spec.rb
|
|
486
487
|
- spec/decimal_contract_spec.rb
|
|
487
488
|
- spec/language_spec.rb
|
|
488
489
|
- spec/lib/api_client_spec.rb
|