"""Unit tests for tasks for handling the generic S3 operations.""" from unittest.mock import call from unittest.mock import MagicMock from unittest.mock import patch import boto3 from botocore.exceptions import ClientError from garcon_contrib.aws.utils import garcon_s3 from moto import mock_aws import pytest from feed_ingestion.tasks import s3_tasks @pytest.fixture def s3(): """Mock S3 for moto.""" with mock_aws(): yield boto3.client('s3') def test_rename_files_on_s3(monkeypatch, s3): """Testing the S3 functions called by rename_files_on_s3.""" files_to_rename = [ ['s3://bucket/path_to_the_file/GoodsIn_ESSN_20160827.csv.done', 's3://bucket/path_to_the_file/GoodsIn_ESSN_20160827.csv'], ['s3://bucket/path_to_the_file/Sales_ESSN_20160829.csv.done', 's3://bucket/path_to_the_file/Sales_ESSN_20160829.csv'], ] monkeypatch.logger = MagicMock() # create moto's virtual bucket and put files there s3.create_bucket(Bucket='bucket') for pair in files_to_rename: bucket_name, bucket_path = garcon_s3.extract_bucket_path(pair[0]) s3.put_object(Bucket=bucket_name, Key=bucket_path, Body='test') s3_tasks.rename_files_on_s3(monkeypatch, files_to_rename) for call_number, call_info in enumerate( monkeypatch.logger.info.mock_calls): assert '{old_file} moved to {new_file}'.format( old_file=files_to_rename[call_number][0], new_file=files_to_rename[call_number][1]) in str(call_info) all_keys = s3.list_objects(Bucket='bucket')['Contents'] assert len(all_keys) == len(files_to_rename) for key in all_keys: key_path = key['Key'] assert key_path.split('.')[-1] == 'csv' assert key_path in files_to_rename[0][1] \ or key_path in files_to_rename[1][1] def test_remove_files_from_path(s3): """Test remove files from path.""" activity_mock = MagicMock() bucket_name = 'source_bucket_r1' path = '/some/path/' full_path = f's3://{bucket_name}/{path}' file_name = 'sample_file.txt' file_and_path = f'{path}{file_name}' file_name_persisted = 'sample_file.txt' file_and_path_persisted = f'/other/path/{file_name_persisted}' # create virtual bucket and file s3.create_bucket(Bucket=bucket_name) s3.put_object(Bucket=bucket_name, Key=file_and_path, Body='test') s3.put_object(Bucket=bucket_name, Key=file_and_path_persisted, Body='test') # call tested method context = s3_tasks.remove_files_from_path( activity=activity_mock, path=full_path, return_deleted_files=True, ) # check output assert context == {'s3.files_removed': [file_and_path]} # check that file doesn't exist all_keys = s3.list_objects(Bucket=bucket_name)['Contents'] assert len(all_keys) == 1 assert all_keys[0]['Key'] == file_and_path_persisted def test_remove_files_from_path_no_files(s3): """Test remove files from path.""" activity_mock = MagicMock() bucket_name = 'source_bucket_r1' path = '/some/path/' full_path = f's3://{bucket_name}/{path}' # create virtual bucket s3.create_bucket(Bucket=bucket_name) # call tested method context = s3_tasks.remove_files_from_path( activity=activity_mock, path=full_path, return_deleted_files=True, ) # check output assert context == {'s3.files_removed': []} existing_keys = s3.list_objects(Bucket=bucket_name) assert 'Contents' not in existing_keys def test_copy_file_successful(monkeypatch, s3): """Test successful copy of files in S3 using copy_file.""" monkeypatch.logger = MagicMock() source_bucket_name = 'source_bucket' source_key_name = 'path/to/source_file.txt' destination_bucket_name = 'destination_bucket' destination_key_name = 'path/to/destination_file.txt' # create virtual source bucket and file s3.create_bucket(Bucket=source_bucket_name) s3.put_object(Bucket=source_bucket_name, Key=source_key_name, Body='test') # create virtual destination bucket s3.create_bucket(Bucket=destination_bucket_name) context = s3_tasks.copy_file( monkeypatch, source_bucket_name, source_key_name, destination_bucket_name, destination_key_name) # check output destination_file_name = destination_key_name.split('/')[-1] assert context == {destination_file_name: True} # check logging monkeypatch.logger.info.assert_called_with( 's3://source_bucket/path/to/source_file.txt copied to ' 's3://destination_bucket/path/to/destination_file.txt') # check file was copied existing_keys = s3.list_objects(Bucket=destination_bucket_name)['Contents'] assert len(existing_keys) == 1 assert existing_keys[0]['Key'] == destination_key_name s3_object = s3.get_object( Bucket=destination_bucket_name, Key=destination_key_name) content = s3_object['Body'].read() assert content == b'test' def test_copy_file_replace_false(monkeypatch, s3): """No logging and returns the context with destination_file_name True.""" monkeypatch.logger = MagicMock() source_bucket_name = 'source_bucket' source_key_name = 'path/to/source_file.txt' destination_bucket_name = 'destination_bucket' destination_key_name = 'path/to/destination_file.txt' destination_file_name = destination_key_name.split('/')[-1] s3.create_bucket(Bucket=destination_bucket_name) s3.put_object( Bucket=destination_bucket_name, Key=destination_key_name, Body='test') context = s3_tasks.copy_file( monkeypatch, source_bucket_name, source_key_name, destination_bucket_name, destination_key_name, replace=False) assert context == {destination_file_name: True} assert monkeypatch.logger.info.call_count == 0 def test_copy_file_replace_false_string(monkeypatch, s3): """No logging and returns the context with destination_file_name True.""" monkeypatch.logger = MagicMock() source_bucket_name = 'source_bucket' source_key_name = 'path/to/source_file.txt' destination_bucket_name = 'destination_bucket' destination_key_name = 'path/to/destination_file.txt' destination_file_name = destination_key_name.split('/')[-1] s3.create_bucket(Bucket=destination_bucket_name) s3.put_object( Bucket=destination_bucket_name, Key=destination_key_name, Body='test') context = s3_tasks.copy_file( monkeypatch, source_bucket_name, source_key_name, destination_bucket_name, destination_key_name, replace='false') assert context == {destination_file_name: True} assert monkeypatch.logger.info.call_count == 0 def test_copy_file_unsuccessful_when_source_bucket_missing(monkeypatch, s3): """Copy unsuccessful using copy_file when source bucket missing.""" monkeypatch.logger = MagicMock() source_bucket_name = 'source_bucket' source_key_name = 'path/to/source_file.txt' destination_bucket_name = 'destination_bucket' destination_key_name = 'path/to/destination_file.txt' destination_file_name = destination_key_name.split('/')[-1] s3.create_bucket(Bucket=destination_bucket_name) context = s3_tasks.copy_file( monkeypatch, source_bucket_name, source_key_name, destination_bucket_name, destination_key_name) assert context == {destination_file_name: False} monkeypatch.logger.info.assert_called_with( 's3://source_bucket/path/to/source_file.txt does not exist') def test_copy_file_unsuccessful_when_destination_bucket_missing( monkeypatch, s3): """Copy unsuccessful using copy_file when destination bucket missing.""" monkeypatch.logger = MagicMock() source_bucket_name = 'source_bucket' source_key_name = 'path/to/source_file.txt' destination_bucket_name = 'destination_bucket' destination_key_name = 'path/to/destination_file.txt' destination_file_name = destination_key_name.split('/')[-1] s3.create_bucket(Bucket=source_bucket_name) context = s3_tasks.copy_file( monkeypatch, source_bucket_name, source_key_name, destination_bucket_name, destination_key_name) assert context == {destination_file_name: False} monkeypatch.logger.info.assert_called_with( 'S3 bucket s3://destination_bucket does not exist') def test_copy_file_unsuccessful_when_source_file_missing(monkeypatch, s3): """Copy unsuccessful using copy_file when no source file exists.""" monkeypatch.logger = MagicMock() source_bucket_name = 'source_bucket' source_key_name = 'path/to/source_file.txt' destination_bucket_name = 'destination_bucket' destination_key_name = 'path/to/destination_file.txt' destination_file_name = destination_key_name.split('/')[-1] s3.create_bucket(Bucket=source_bucket_name) s3.create_bucket(Bucket=destination_bucket_name) context = s3_tasks.copy_file( monkeypatch, source_bucket_name, source_key_name, destination_bucket_name, destination_key_name) assert context == {destination_file_name: False} monkeypatch.logger.info.assert_called_with( 's3://source_bucket/path/to/source_file.txt does not exist') @pytest.fixture def context_copy_files(): """Return context for grab_drop_files.""" source_files_dict = {'files': [ { 'file_name': 'file_1' }, { 'file_name': 'file_2' }]} return (dict( activity=MagicMock(), s3_archive_path='s3://s3_archive_path/', s3_download_path='s3://s3_download_path/', source_files_dict=source_files_dict, need_all_files=True)) @pytest.fixture def mock_copy_s3_key(monkeypatch): """Mock function copy_s3_key.""" with patch.object(s3_tasks, 'copy_s3_key') as mock_copy: yield mock_copy @pytest.fixture def mock_get_key_size(monkeypatch): """Mock function copy_s3_key.""" with patch.object(s3_tasks, 'get_key_size') as mock_size: mock_size.return_value = 1 yield mock_size @pytest.fixture def mock_get_list_of_files_and_directories(monkeypatch): """Mock function mock_get_list_of_files_and_directories.""" with patch.object(s3_tasks, 'get_list_of_files_and_directories') as mock: yield mock def test_copy_files_successful( context_copy_files, mock_get_key_size, mock_copy_s3_key): """Should call copy_s3_key several times.""" result = s3_tasks.copy_files(**context_copy_files) expected_result = { 'source_files_dict': {'files': [ { 'file_name': 'file_1', 'found': True, 'file_size': 1, 'file_path': 's3://s3_download_path/file_1' }, { 'file_name': 'file_2', 'found': True, 'file_size': 1, 'file_path': 's3://s3_download_path/file_2' }]}} mock_copy_s3_key.assert_has_calls([ call('s3://s3_download_path/file_1', 's3://s3_archive_path/file_1'), call('s3://s3_download_path/file_2', 's3://s3_archive_path/file_2')]) mock_get_key_size.assert_has_calls([ call('s3://s3_archive_path/file_1'), call('s3://s3_archive_path/file_2')]) assert result == expected_result def test_copy_files_unsuccessful( context_copy_files, mock_copy_s3_key, mock_get_key_size): """Should return STOP_RESPONSE if copying files failed.""" mock_copy_s3_key.side_effect = ClientError( {'Error': {'Code': '404', 'Message': 'Not found'}}, 'Not found') assert s3_tasks.copy_files( **context_copy_files) == dict( stop=True, msg='Need all files to proceed. Download result: ' '"[{\'file_name\': \'file_1\', \'file_size\': 0, ' "'file_path': 's3://s3_download_path/file_1', " "'found': False}, {'file_name': " "'file_2', 'file_size': 0, 'file_path': " "'s3://s3_download_path/file_2', 'found': " 'False}]"') mock_copy_s3_key.assert_has_calls([ call('s3://s3_download_path/file_1', 's3://s3_archive_path/file_1'), call('s3://s3_download_path/file_2', 's3://s3_archive_path/file_2')]) mock_get_key_size.assert_not_called() @pytest.fixture def mock_get_key(): """Mock _get_key.""" with patch.object(s3_tasks, '_get_key') as _get_key: yield _get_key @pytest.fixture def mock_boto3(): """Mock boto3.""" with patch.object(s3_tasks, 'boto3') as boto3: mock_client = MagicMock() boto3.client.return_value = mock_client yield boto3 @pytest.fixture def mock_boto3_fail(): """Mock boto3 with raising error.""" boto3_path = 'boto3' with patch.object(s3_tasks, boto3_path) as boto3: mock_client = MagicMock() boto3.client.return_value = mock_client mock_client.download_fileobj.side_effect = ClientError( {'Error': {'Code': '404', 'Message': 'Not found'}}, 'Not found') yield boto3 @pytest.fixture def mock_upload_on_s3(): """Mock upload_on_s3.""" boto3_path = 'upload_on_s3' with patch.object(s3_tasks, boto3_path) as upload_on_s3: yield upload_on_s3 @pytest.fixture def mock_temp_file(): """Yield temp_file.""" path = 'NamedTemporaryFile' with patch.object(s3_tasks, path) as temp_file: mock_file = temp_file.return_value.__enter__.return_value yield mock_file @pytest.fixture def mock_os(): """Yield mock os.""" path = 'os' with patch.object(s3_tasks, path) as os_module: yield os_module @pytest.fixture def context_copy_file_from_sme_s3_to_theocrhard(): """Return context for copy_file_from_sme_s3_to_theocrhard.""" return { 'activity': MagicMock(), 'secrets_path': 'secrets_path', 'source_bucket_name': 'source_bucket', 'source_key_name': 'path/to/source_file.txt', 'destination_bucket_name': 'destination_bucket', 'destination_key_name': 'path/to/destination_file.txt' } def test_copy_file_from_sme_s3_to_theocrhard_successful( mock_get_key, mock_boto3, mock_upload_on_s3, mock_get_key_size, mock_temp_file, context_copy_file_from_sme_s3_to_theocrhard, mock_os): """Test successful copy_file_from_sme_s3_to_theocrhard.""" mock_os.path.getsize.return_value = 1 context = s3_tasks.copy_file_from_sme_s3_to_theocrhard( **context_copy_file_from_sme_s3_to_theocrhard) assert context == {'destination_file.txt': True, 'file_size': 1} def test_copy_file_from_sme_s3_to_theocrhard_no_file_on_sme_s3( mock_get_key, mock_boto3_fail, mock_upload_on_s3, mock_get_key_size, mock_temp_file, context_copy_file_from_sme_s3_to_theocrhard): """Test failed copy_file_from_sme_s3_to_theocrhard.""" context = s3_tasks.copy_file_from_sme_s3_to_theocrhard( **context_copy_file_from_sme_s3_to_theocrhard) assert context == {'destination_file.txt': False} def test_copy_file_from_sme_s3_to_theocrhard_size_files_are_different( mock_get_key, mock_boto3, mock_upload_on_s3, mock_get_key_size, mock_temp_file, context_copy_file_from_sme_s3_to_theocrhard, mock_os): """Test when size of temp file != size of copied filed on s3.""" mock_os.path.getsize.return_value = 2 context = s3_tasks.copy_file_from_sme_s3_to_theocrhard( **context_copy_file_from_sme_s3_to_theocrhard) assert context == {'destination_file.txt': 'Uploading failed'} @pytest.mark.parametrize('files, file_pattern, expected_files', [ # 1. Test 2 matching files ( [ 'match_file_1.gz', 'match_file_2.gz', ], r'^match_file_\d+\.gz$', [ {'file_name': 'match_file_1.gz', 'file_size': 42}, {'file_name': 'match_file_2.gz', 'file_size': 42}, ] ), # 2. Test 1 matching file and 2 non matching files ( [ 'match_file_1.gz', 'subdir/match_file_1.gz', 'not_match_file_2.gz', ], r'^match_file_\d+\.gz$', [ {'file_name': 'match_file_1.gz', 'file_size': 42}, ] ), # 2. Empty matching files should raise ( [ ], r'^match_file_\d+\.gz$', ValueError ), ]) def test_source_files( mock_get_key_size, mock_get_list_of_files_and_directories, files, file_pattern, expected_files): """Test source_files.""" def get_key_size_side_effect(key): if any(True for file in files if key.endswith(file)): return 42 # actual error will be botocore.errorfactory.NoSuchKey raise ValueError('No such file') mock_get_key_size.side_effect = get_key_size_side_effect mock_get_list_of_files_and_directories.return_value = files if expected_files == ValueError: with pytest.raises(expected_files): s3_tasks.source_files( activity=MagicMock(), s3_bucket=MagicMock(), s3_path='/archive', file_pattern=file_pattern, ) else: expected_result = {'source_files_dict': {'files': expected_files}} result = s3_tasks.source_files( activity=MagicMock(), s3_bucket=MagicMock(), s3_path='/archive', file_pattern=file_pattern, ) assert result == expected_result @pytest.mark.parametrize('string, prefix, result', ( ('qwerty', 'qw', 'erty'), ('qwerty', 'qwerty', ''), ('qwerty', 'NOqwerty', 'qwerty'), ('/path/file', '/path/', 'file'), ('/path/file', '/path', '/file'), ('/path/file', '/nopath', '/path/file'), )) def test_remove_prefix(string, prefix, result): """Test remove_prefix function.""" assert s3_tasks.remove_prefix(string, prefix) == result