from unittest import mock import faker import freezegun import pytest from fansifter_common.adapters.sendgrid.models import FanData from fansifter_common.utils import timezone from app.exceptions import EmailSendError, EmailSendSkipError from app.handler import SendEmailsHandler, SendEmailsRequest from app.models import BatchRecipient, Campaign, CampaignBatch, Domain from tests.unit.types import CreateModel, OverrideSettings class TestSendEmailsHandler: @pytest.mark.db @freezegun.freeze_time("2024-01-01 12:10:00") def test_handle( self, handler: SendEmailsHandler, create_model: CreateModel, stripo_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) campaign = create_model( Campaign, domain_id=domain.id, status="IN_PROGRESS", compressed_content=None ) batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) create_model( BatchRecipient, batch_id=batch.id, fan_email="fan@sonymusic-pde.com" ) result = handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) assert result assert result.campaign_id == campaign.id assert result.batch_id == batch.id assert result.sent_emails == 1 assert batch.is_completed assert batch.batch_offset == 1 assert batch.last_hour_sent_emails == 1 stripo_client_mock.compress.assert_called_once() @pytest.mark.db @freezegun.freeze_time("2024-01-01 12:10:00") def test_handle_skip_compression( self, handler: SendEmailsHandler, create_model: CreateModel, stripo_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) campaign = create_model( Campaign, domain_id=domain.id, status="IN_PROGRESS", compressed_content="compressed", ) batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) create_model( BatchRecipient, batch_id=batch.id, fan_email="fan@sonymusic-pde.com" ) handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) stripo_client_mock.compress.assert_not_called() @pytest.mark.db def test_handle_with_dispatch_time( self, handler: SendEmailsHandler, create_model: CreateModel ) -> None: dispatch_time = timezone.now() domain = create_model(Domain) campaign = create_model(Campaign, domain_id=domain.id, status="IN_PROGRESS") batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) create_model( BatchRecipient, batch_id=batch.id, fan_email="fan@sonymusic-pde.com" ) handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, dispatch_time=dispatch_time, ) ) assert batch.last_sent_at == dispatch_time @pytest.mark.db def test_handle_batch_not_found(self, handler: SendEmailsHandler) -> None: with pytest.raises(EmailSendError, match="Batch (.*?) is not found."): handler.handle( SendEmailsRequest( batch_id=9999, campaign_id="campaign_id", current_hourly_quota=20.0, emails_to_send=1000, ) ) @pytest.mark.db def test_handle_batch_not_active_returns_none( self, handler: SendEmailsHandler, create_model: CreateModel ) -> None: """Test that inactive batch returns None gracefully instead of raising error. This handles the race condition where multiple Lambda invocations may process the same batch (e.g., after timeout retries). """ domain = create_model(Domain) campaign = create_model(Campaign, domain_id=domain.id, status="IN_PROGRESS") batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, cancelled_at=timezone.now(), ) with pytest.raises(EmailSendSkipError, match="Batch (.*?) is not active."): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) @pytest.mark.db def test_handle_campaign_not_found( self, handler: SendEmailsHandler, create_model: CreateModel, faker: faker.Faker ) -> None: batch = create_model( CampaignBatch, campaign_id=faker.uuid4(), batch_offset=0, batch_size=1, ) with pytest.raises(EmailSendError, match="Campaign (.*?) is not found."): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=faker.uuid4(), current_hourly_quota=20, emails_to_send=1, ) ) @pytest.mark.db def test_handle_campaign_not_active( self, handler: SendEmailsHandler, create_model: CreateModel ) -> None: domain = create_model(Domain) campaign = create_model(Campaign, domain_id=domain.id, status="CANCELLED") batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) with pytest.raises(EmailSendSkipError, match="Campaign (.*?) is not active."): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) @pytest.mark.db def test_handle_campaign_missing_sender_details( self, handler: SendEmailsHandler, create_model: CreateModel ) -> None: domain = create_model(Domain) campaign = create_model( Campaign, domain_id=domain.id, status="SCHEDULED", email_username=None, ) batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) with pytest.raises( EmailSendError, match="Email username or sender details are missing." ): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) @pytest.mark.db def test_handle_campaign_missing_content( self, handler: SendEmailsHandler, create_model: CreateModel ) -> None: domain = create_model(Domain) campaign = create_model( Campaign, domain_id=domain.id, status="SCHEDULED", html_content=None, ) batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) with pytest.raises( EmailSendError, match="Campaign subject or content is missing." ): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) @pytest.mark.db def test_handle_campaign_missing_domain( self, handler: SendEmailsHandler, create_model: CreateModel ) -> None: campaign = create_model( Campaign, status="SCHEDULED", domain_id=None, ) batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) with pytest.raises(EmailSendError, match="Campaign email domain is not set."): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) @pytest.mark.db def test_handle_campaign_domain_not_found( self, handler: SendEmailsHandler, create_model: CreateModel, faker: faker.Faker ) -> None: campaign = create_model(Campaign, status="SCHEDULED", domain_id=faker.uuid4()) batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) with pytest.raises(EmailSendError, match="Campaign email domain is not found."): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) @pytest.mark.db @freezegun.freeze_time("2024-01-01 12:10:00") def test_handle_seedlist_sent( self, handler: SendEmailsHandler, create_model: CreateModel, override_settings: OverrideSettings, seedlist_service_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) campaign = create_model(Campaign, domain_id=domain.id, status="SCHEDULED") batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) create_model(BatchRecipient, batch_id=batch.id, fan_email="fan@mail.com") seedlist_service_mock.get_seedlist_emails.return_value = ["user@mail.com"] seedlist_service_mock.create_seedlist_fan_data.return_value = [ FanData( email="user@mail.com", profile_token=None, country_iso2="US", privacy_links_block="Privacy Links", legal_entity_name="Legal Entity Name", legal_entity_address="Legal Entity Address", opt_in_info="Opt In Info", unsubscribe_text="Unsubscribe Text", opt_in_info_external="Opt In Info External", ) ] with override_settings(seedlist_enabled=True): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) sendgrid_client_mock.send_mail.assert_called_once() @pytest.mark.db @freezegun.freeze_time("2024-01-01 12:10:00") def test_handle_skip_sendgrid_send_by_special_tag( self, handler: SendEmailsHandler, create_model: CreateModel, override_settings: OverrideSettings, sendgrid_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) campaign = create_model( Campaign, domain_id=domain.id, status="SCHEDULED", subject="#automationtest" ) batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1 ) create_model(BatchRecipient, batch_id=batch.id, fan_email="fan@gmail.com") with override_settings(send_fansifter_only=False, environment="qa"): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) sendgrid_client_mock.send_mail.assert_not_called() @pytest.mark.db @freezegun.freeze_time("2024-01-01 12:00:00") def test_update_batch_progress( self, handler: SendEmailsHandler, create_model: CreateModel ) -> None: campaign = create_model(Campaign, status="IN_PROGRESS") batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=10, completed_at=None, cancelled_at=None, ) handler.update_batch_progress( batch=batch, batch_offset=10, sent_emails=10, current_hourly_quota=15.0, send_time=timezone.now(), ) assert batch.is_completed @pytest.mark.db @freezegun.freeze_time("2024-01-01 12:10:00") def test_send_emails_batch_becomes_inactive_after_lock( self, handler: SendEmailsHandler, create_model: CreateModel, sendgrid_client_mock: mock.MagicMock, ) -> None: """Test race condition: batch becomes inactive after acquiring lock. This simulates the scenario where: 1. Lambda A and Lambda B both start processing the same batch 2. Lambda A acquires the lock first, processes emails, marks batch complete 3. Lambda B acquires the lock and finds batch is now inactive 4. Lambda B should return gracefully with sent_emails=0 """ domain = create_model(Domain) campaign = create_model(Campaign, domain_id=domain.id, status="IN_PROGRESS") # Create a batch that is active initially batch = create_model( CampaignBatch, campaign_id=campaign.id, batch_offset=0, batch_size=1, ) create_model( BatchRecipient, batch_id=batch.id, fan_email="fan@sonymusic-pde.com" ) # First invocation processes normally result1 = handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) assert result1 is not None assert result1.sent_emails == 1 assert batch.is_completed # Second invocation (simulating retry after timeout) should raise skip error with pytest.raises(EmailSendSkipError, match="Batch (.*?) is not active."): handler.handle( SendEmailsRequest( batch_id=batch.id, campaign_id=campaign.id, current_hourly_quota=20, emails_to_send=1, ) ) # SendGrid should only have been called once (by first invocation) assert sendgrid_client_mock.send_mail.call_count == 1