|
| 1 | +import json |
| 2 | +from contextlib import nullcontext |
1 | 3 | import pytest |
2 | 4 | from unittest.mock import patch, mock_open |
3 | 5 | from unittest.mock import MagicMock |
4 | 6 | 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 |
7 | 9 | from itertools import product |
8 | 10 | from tempfile import mkstemp |
9 | 11 | import os |
10 | 12 |
|
| 13 | +# sentinel to make intent explicit and remove typo possibilities |
11 | 14 | TMP_PATH_SYMBOL = object() |
| 15 | +STR_PATH_SYMBOL = object() |
| 16 | +FILE_LIKE_SYMBOL = object() |
12 | 17 |
|
13 | 18 | class MockClient: |
14 | 19 | def get(self, *args, **kwargs): |
@@ -123,3 +128,200 @@ def test_upload(filelike, kwargs, upload_fixture): |
123 | 128 | else: |
124 | 129 | raise RuntimeError(f" should be either str or IOBase") |
125 | 130 | 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