Skip to content

Commit 6972a34

Browse files
committed
test: add tests to bucket multipart uploads
1 parent 1890415 commit 6972a34

1 file changed

Lines changed: 204 additions & 2 deletions

File tree

tests/test_bucket.py

Lines changed: 204 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
import json
2+
from contextlib import nullcontext
13
import pytest
24
from unittest.mock import patch, mock_open
35
from unittest.mock import MagicMock
46
from ebrains_drive.bucket import Bucket
5-
from ebrains_drive.exceptions import ClientHttpError, Unauthorized
6-
from io import StringIO, IOBase
7+
from ebrains_drive.exceptions import ClientHttpError, Unauthorized, InvalidParameter, UpstreamAPIException
8+
from io import StringIO, BytesIO, IOBase
79
from itertools import product
810
from tempfile import mkstemp
911
import os
1012

13+
# sentinel to make intent explicit and remove typo possibilities
1114
TMP_PATH_SYMBOL = object()
15+
STR_PATH_SYMBOL = object()
16+
FILE_LIKE_SYMBOL = object()
1217

1318
class MockClient:
1419
def get(self, *args, **kwargs):
@@ -123,3 +128,200 @@ def test_upload(filelike, kwargs, upload_fixture):
123128
else:
124129
raise RuntimeError(f" should be either str or IOBase")
125130
mocked_request.assert_called_with("PUT", "http://foo-bar.co/", data=data, **kwargs)
131+
132+
133+
# Patch to 64 bytes so tests don't need large files on disk.
134+
SMALL_CHUNK_SIZE = 64
135+
136+
137+
def _make_bucket():
138+
client = MockClient()
139+
return Bucket.from_json(client, bucket_json)
140+
141+
142+
# --- _get_filesize ---
143+
144+
@pytest.mark.parametrize("make_input,expected_size,check_seek", [
145+
pytest.param(STR_PATH_SYMBOL, 11, False, id="str-path"),
146+
pytest.param(FILE_LIKE_SYMBOL, 42, True, id="file-like"),
147+
])
148+
def test_get_filesize(tmp_path, make_input, expected_size, check_seek):
149+
bucket = _make_bucket()
150+
if make_input is STR_PATH_SYMBOL:
151+
p = tmp_path / "sample.bin"
152+
p.write_bytes(b"hello world")
153+
input_ = str(p)
154+
else:
155+
input_ = BytesIO(b"x" * 42)
156+
input_.seek(5) # advance pointer to confirm it gets reset
157+
assert bucket._get_filesize(input_) == expected_size
158+
if check_seek:
159+
assert input_.tell() == 0 # pointer restored to start
160+
161+
162+
# --- _can_multipart_upload ---
163+
164+
def _write(path, data):
165+
path.write_bytes(data)
166+
return path
167+
168+
@pytest.mark.parametrize("make_input,expected_size,raises", [
169+
(lambda tmp_path: str(_write(tmp_path / "big.bin", b"0" * (SMALL_CHUNK_SIZE + 1))), SMALL_CHUNK_SIZE + 1, False),
170+
(lambda tmp_path: str(_write(tmp_path / "small.bin", b"tiny")), None, True),
171+
(lambda tmp_path: str(_write(tmp_path / "exact.bin", b"0" * SMALL_CHUNK_SIZE)), None, True),
172+
(lambda tmp_path: BytesIO(b"0" * (SMALL_CHUNK_SIZE + 10)), SMALL_CHUNK_SIZE + 10, False),
173+
])
174+
@patch("ebrains_drive.bucket.EBRAINS_DRIVE_MULTIPART_CHUNK_SIZE", SMALL_CHUNK_SIZE)
175+
def test_can_multipart_upload(tmp_path, make_input, expected_size, raises):
176+
src = make_input(tmp_path)
177+
bucket = _make_bucket()
178+
if raises:
179+
with pytest.raises(InvalidParameter):
180+
bucket._can_multipart_upload(src)
181+
else:
182+
assert bucket._can_multipart_upload(src) == expected_size
183+
184+
185+
# --- upload falls back to multipart_upload ---
186+
187+
SMALL_THRESHOLD = 128
188+
189+
190+
@pytest.mark.parametrize("file_size,expect_multipart", [
191+
pytest.param(SMALL_THRESHOLD + 1, True, id="exceeds-threshold"),
192+
pytest.param(SMALL_THRESHOLD, False, id="at-threshold"),
193+
])
194+
@patch("ebrains_drive.bucket.EBRAINS_DRIVE_MULTIPART_THRESHOLD", SMALL_THRESHOLD)
195+
def test_upload_multipart_threshold(tmp_path, file_size, expect_multipart):
196+
f = tmp_path / "file.bin"
197+
f.write_bytes(b"x" * file_size)
198+
199+
mock_client = MockClient()
200+
mock_client.put = MagicMock(return_value=MockHttpResp({"url": "http://example.com/upload"}))
201+
bucket = Bucket.from_json(mock_client, bucket_json)
202+
203+
with patch.object(bucket, "multipart_upload") as mock_multipart:
204+
with patch("requests.request", return_value=MockHttpResp({})):
205+
bucket.upload(str(f), "dest/file.bin", extra="kwarg")
206+
if expect_multipart:
207+
mock_multipart.assert_called_once_with(str(f), "dest/file.bin", extra="kwarg")
208+
else:
209+
mock_multipart.assert_not_called()
210+
211+
212+
# --- multipart_upload ---
213+
214+
MULTIPART_CHUNK_SIZE = 64
215+
216+
217+
def _make_multipart_bucket():
218+
client = MockClient()
219+
client.put = MagicMock()
220+
return Bucket.from_json(client, bucket_json), client
221+
222+
223+
class MockPresignedResp:
224+
"""Simulates a presigned-URL PUT response with an ETag header."""
225+
226+
def __init__(self, etag="abc123"):
227+
self.headers = {"etag": f'"{etag}"'}
228+
229+
def raise_for_status(self):
230+
pass
231+
232+
233+
@pytest.mark.parametrize("use_file_path,data,expected_part_count", [
234+
(True, b"A" * (MULTIPART_CHUNK_SIZE * 2 + 10), 3),
235+
(False, b"B" * (MULTIPART_CHUNK_SIZE + 5), 2),
236+
])
237+
@patch("ebrains_drive.bucket.EBRAINS_DRIVE_MULTIPART_CHUNK_SIZE", MULTIPART_CHUNK_SIZE)
238+
def test_multipart_upload_fresh(tmp_path, use_file_path, data, expected_part_count):
239+
"""Full fresh upload from a file path or BytesIO: obtains uploadId, uploads parts, completes."""
240+
presigned_url = "http://presigned.example.com/part"
241+
242+
if use_file_path:
243+
f = tmp_path / "big.bin"
244+
f.write_bytes(data)
245+
source = str(f)
246+
dest = "dest/big.bin"
247+
else:
248+
source = BytesIO(data)
249+
dest = "dest/stream.bin"
250+
251+
bucket, client = _make_multipart_bucket()
252+
client.put.side_effect = (
253+
[MockHttpResp({"uploadId": "uid-fresh"})]
254+
+ [MockHttpResp({"url": presigned_url})] * expected_part_count
255+
+ [MockHttpResp({})]
256+
)
257+
258+
with patch("requests.Session") as MockSession:
259+
sess = MockSession.return_value
260+
sess.put.return_value = MockPresignedResp("etag-x")
261+
bucket.multipart_upload(source, dest)
262+
263+
assert sess.put.call_count == expected_part_count
264+
if use_file_path:
265+
assert not os.path.exists(source + ".multipart_manifest.json")
266+
267+
268+
@patch("ebrains_drive.bucket.EBRAINS_DRIVE_MULTIPART_CHUNK_SIZE", MULTIPART_CHUNK_SIZE)
269+
def test_multipart_upload_resumes_from_manifest(tmp_path):
270+
"""If a manifest exists, upload resumes from next_offset without re-uploading completed parts."""
271+
chunk = MULTIPART_CHUNK_SIZE
272+
data = b"C" * (chunk * 3)
273+
f = tmp_path / "resume.bin"
274+
f.write_bytes(data)
275+
276+
upload_id = "uid-resume"
277+
existing_etags = {"1": "etag-part1"}
278+
manifest = {
279+
"upload_id": upload_id,
280+
"etag_maps": existing_etags,
281+
"next_offset": chunk, # first chunk already uploaded
282+
}
283+
manifest_path = str(f) + ".multipart_manifest.json"
284+
with open(manifest_path, "w") as mf:
285+
json.dump(manifest, mf)
286+
287+
bucket, client = _make_multipart_bucket()
288+
presigned_url = "http://presigned.example.com/part"
289+
290+
client.put.side_effect = [
291+
MockHttpResp({"url": presigned_url}), # part 2
292+
MockHttpResp({"url": presigned_url}), # part 3
293+
MockHttpResp({}), # complete
294+
]
295+
296+
with patch("requests.Session") as MockSession:
297+
sess = MockSession.return_value
298+
sess.put.return_value = MockPresignedResp("etag-resumed")
299+
bucket.multipart_upload(str(f), "dest/resume.bin")
300+
301+
# Only 2 parts uploaded (part 1 was already done)
302+
assert sess.put.call_count == 2
303+
# Manifest cleaned up after success
304+
assert not os.path.exists(manifest_path)
305+
306+
307+
@pytest.mark.parametrize("put_side_effect,use_session", [
308+
(MockHttpResp({}), False), # no uploadId
309+
([MockHttpResp({"uploadId": "uid-nourl"}), MockHttpResp({})], True), # no presigned URL
310+
])
311+
@patch("ebrains_drive.bucket.EBRAINS_DRIVE_MULTIPART_CHUNK_SIZE", MULTIPART_CHUNK_SIZE)
312+
def test_multipart_upload_raises_on_missing_server_field(tmp_path, put_side_effect, use_session):
313+
"""UpstreamAPIException raised when the server omits uploadId or a presigned URL."""
314+
data = b"D" * (MULTIPART_CHUNK_SIZE + 1)
315+
f = tmp_path / "bad.bin"
316+
f.write_bytes(data)
317+
318+
bucket, client = _make_multipart_bucket()
319+
if isinstance(put_side_effect, list):
320+
client.put.side_effect = put_side_effect
321+
else:
322+
client.put.return_value = put_side_effect
323+
324+
ctx = patch("requests.Session") if use_session else nullcontext()
325+
with ctx:
326+
with pytest.raises(UpstreamAPIException):
327+
bucket.multipart_upload(str(f), "dest/bad.bin")

0 commit comments

Comments
 (0)