"""Unit tests for the S3 storage module.""" import json from unittest.mock import MagicMock, patch import pytest from src.logic import s3 def test_load_from_s3_returns_parsed_json() -> None: """load_from_s3 parses the S3 URI and returns the decoded JSON body.""" payload = {"result": "ok"} mock_client = MagicMock() mock_client.get_object.return_value = { "Body": MagicMock( read=MagicMock(return_value=json.dumps(payload).encode()) ) } s3._client.cache_clear() with ( patch.object(s3.config, "EXPECTED_BUCKET_OWNER", "111122223333"), patch.object(s3, "boto3") as mock_boto3, ): mock_boto3.client.return_value = mock_client result = s3.load_from_s3("s3://my-bucket/path/to/key.json") assert result == payload mock_client.get_object.assert_called_once_with( Bucket="my-bucket", Key="path/to/key.json", ExpectedBucketOwner="111122223333", ) def test_load_from_s3_passes_expected_bucket_owner_from_config() -> None: """ExpectedBucketOwner is taken from config and forwarded to get_object.""" mock_client = MagicMock() mock_client.get_object.return_value = { "Body": MagicMock(read=MagicMock(return_value=b"{}")) } s3._client.cache_clear() with ( patch.object(s3.config, "EXPECTED_BUCKET_OWNER", "999988887777"), patch.object(s3, "boto3") as mock_boto3, ): mock_boto3.client.return_value = mock_client s3.load_from_s3("s3://my-bucket/key.json") kwargs = mock_client.get_object.call_args.kwargs assert kwargs["ExpectedBucketOwner"] == "999988887777" def test_build_object_key_joins_prefix_and_path() -> None: """Object key is prefix joined to the output path with a .json extension.""" assert ( s3.build_object_key("results", "29366/0/2427859") == "results/29366/0/2427859.json" ) def test_build_object_key_strips_trailing_slashes() -> None: """Trailing slashes on either argument are normalized away.""" assert ( s3.build_object_key("results/", "29366/0/2427859/") == "results/29366/0/2427859.json" ) def test_build_object_key_empty_prefix_no_leading_slash() -> None: """An empty prefix produces no leading slash in the key.""" assert s3.build_object_key("", "29366/0/2427859") == "29366/0/2427859.json" def test_store_result_puts_object_and_returns_url() -> None: """store_result calls put_object with JSON body and returns s3:// url.""" mock_client = MagicMock() s3._client.cache_clear() with ( patch.object(s3.config, "RESULTS_BUCKET", "qa-asgv-results"), patch.object(s3.config, "RESULTS_BUCKET_PREFIX", "results"), patch.object(s3.config, "EXPECTED_BUCKET_OWNER", None), patch.object(s3, "boto3") as mock_boto3, ): mock_boto3.client.return_value = mock_client url = s3.store_result("a/b/c/d", {"foo": "bar"}) assert url == "s3://qa-asgv-results/results/a/b/c/d.json" mock_client.put_object.assert_called_once() kwargs = mock_client.put_object.call_args.kwargs assert kwargs["Bucket"] == "qa-asgv-results" assert kwargs["Key"] == "results/a/b/c/d.json" assert kwargs["ContentType"] == "application/json" assert json.loads(kwargs["Body"]) == {"foo": "bar"} assert "ExpectedBucketOwner" not in kwargs def test_store_result_includes_expected_bucket_owner_when_set() -> None: """ExpectedBucketOwner is forwarded when configured.""" mock_client = MagicMock() s3._client.cache_clear() with ( patch.object(s3.config, "RESULTS_BUCKET", "qa-asgv-results"), patch.object(s3.config, "RESULTS_BUCKET_PREFIX", "results"), patch.object(s3.config, "EXPECTED_BUCKET_OWNER", "111122223333"), patch.object(s3, "boto3") as mock_boto3, ): mock_boto3.client.return_value = mock_client s3.store_result("a/b/c/d", {}) kwargs = mock_client.put_object.call_args.kwargs assert kwargs["ExpectedBucketOwner"] == "111122223333" def test_store_result_raises_when_bucket_unconfigured() -> None: """Missing bucket configuration is a hard error.""" s3._client.cache_clear() with patch.object(s3.config, "RESULTS_BUCKET", None): with pytest.raises(ValueError, match="RESULTS_BUCKET"): s3.store_result("a/b/c/d", {}) def test_store_result_raises_when_bucket_prefix_unconfigured() -> None: """Missing bucket prefix configuration is a hard error.""" s3._client.cache_clear() with ( patch.object(s3.config, "RESULTS_BUCKET", "qa-asgv-results"), patch.object(s3.config, "RESULTS_BUCKET_PREFIX", None), ): with pytest.raises(ValueError, match="RESULTS_BUCKET_PREFIX"): s3.store_result("a/b/c/d", {})