"""Tests for main.py.""" from unittest.mock import call from unittest.mock import MagicMock from unittest.mock import patch import pytest from requests.exceptions import ConnectionError from requests.exceptions import ReadTimeout import main @pytest.fixture() def mock_repo(): """Return a mock GitHub repository object.""" repo = MagicMock() repo.archive.return_value = True return repo @pytest.fixture(autouse=True) def mock_s3_client(monkeypatch): """Patch the global S3 client in main with a mock.""" mock = MagicMock() monkeypatch.setattr(main, 's3', mock) return mock @pytest.fixture(autouse=True) def fast_retries(monkeypatch): """Set retry config to fast values to keep tests quick.""" monkeypatch.setattr(main.config, 'ARCHIVE_MAX_RETRIES', 3) monkeypatch.setattr(main.config, 'ARCHIVE_RETRY_WAIT', 0) def test_backup_repo_success(mock_repo, mock_s3_client, monkeypatch): """Test that a repo is archived, uploaded to S3, and the local file is cleaned up.""" repo_name = 'my-repo' bucket_name = 'test-bucket' monkeypatch.setattr(main, 'exists', lambda path: path == repo_name) mock_remove = MagicMock() monkeypatch.setattr(main.os, 'remove', mock_remove) main.backup_repo(mock_repo, repo_name, bucket_name) mock_repo.archive.assert_called_once_with('zipball', path=repo_name, ref='master') mock_s3_client.upload_file.assert_called_once_with( repo_name, bucket_name, f'{repo_name}/{repo_name}', ExtraArgs={'ServerSideEncryption': 'aws:kms', 'SSEKMSKeyId': main.config.KMS_KEY_ID}, ) mock_remove.assert_called_once_with(repo_name) def test_backup_repo_empty_repo(mock_repo, mock_s3_client, monkeypatch): """Test that an empty repo is skipped without uploading to S3.""" monkeypatch.setattr(main, 'exists', lambda path: False) main.backup_repo(mock_repo, 'empty-repo', 'test-bucket') mock_s3_client.upload_file.assert_not_called() def test_backup_repo_retries_on_read_timeout(mock_repo, mock_s3_client, monkeypatch): """Test that archive is retried when ReadTimeout is raised on the first attempt.""" monkeypatch.setattr(main, 'exists', lambda path: False) mock_repo.archive.side_effect = [ReadTimeout(), True] with patch('main.time.sleep') as mock_sleep: main.backup_repo(mock_repo, 'my-repo', 'test-bucket') assert mock_repo.archive.call_count == 2 mock_sleep.assert_called_once_with(0) def test_backup_repo_retries_on_connection_error(mock_repo, mock_s3_client, monkeypatch): """Test that archive is retried when ConnectionError is raised on the first attempt.""" monkeypatch.setattr(main, 'exists', lambda path: False) mock_repo.archive.side_effect = [ConnectionError(), True] with patch('main.time.sleep'): main.backup_repo(mock_repo, 'my-repo', 'test-bucket') assert mock_repo.archive.call_count == 2 def test_backup_repo_raises_after_max_retries(mock_repo, mock_s3_client, monkeypatch): """Test that the exception is re-raised once all retries are exhausted.""" monkeypatch.setattr(main, 'exists', lambda path: False) mock_repo.archive.side_effect = ReadTimeout() with patch('main.time.sleep'): with pytest.raises(ReadTimeout): main.backup_repo(mock_repo, 'my-repo', 'test-bucket') assert mock_repo.archive.call_count == main.config.ARCHIVE_MAX_RETRIES def test_backup_repo_cleans_up_partial_file_on_retry(mock_repo, mock_s3_client, monkeypatch): """Test that a partial local file is removed before retrying.""" repo_name = 'my-repo' # First exists() call is inside the except block (partial file check); # second is after the successful archive (empty repo check). monkeypatch.setattr(main, 'exists', MagicMock(side_effect=[True, False])) mock_remove = MagicMock() monkeypatch.setattr(main.os, 'remove', mock_remove) mock_repo.archive.side_effect = [ReadTimeout(), True] with patch('main.time.sleep'): main.backup_repo(mock_repo, repo_name, 'test-bucket') mock_remove.assert_called_once_with(repo_name) def test_backup_repo_no_cleanup_when_no_partial_file(mock_repo, mock_s3_client, monkeypatch): """Test that os.remove is not called when no partial file exists on failure.""" monkeypatch.setattr(main, 'exists', lambda path: False) mock_remove = MagicMock() monkeypatch.setattr(main.os, 'remove', mock_remove) mock_repo.archive.side_effect = [ReadTimeout(), True] with patch('main.time.sleep'): main.backup_repo(mock_repo, 'my-repo', 'test-bucket') mock_remove.assert_not_called() def test_backup_repo_exponential_backoff(mock_repo, mock_s3_client, monkeypatch): """Test that retry wait times follow exponential backoff.""" monkeypatch.setattr(main.config, 'ARCHIVE_MAX_RETRIES', 4) monkeypatch.setattr(main.config, 'ARCHIVE_RETRY_WAIT', 10) monkeypatch.setattr(main, 'exists', lambda path: False) mock_repo.archive.side_effect = [ReadTimeout(), ReadTimeout(), ReadTimeout(), True] with patch('main.time.sleep') as mock_sleep: main.backup_repo(mock_repo, 'my-repo', 'test-bucket') mock_sleep.assert_has_calls([call(10), call(20), call(40)])