from unittest import mock import pytest from fansifter_common.adapters.sendgrid.exceptions import SendGridClientError from fansifter_common.utils import timezone from app.exceptions import EmailSendError from app.handler import _parse_csv, handle from app.models import AutomatedEmail, AutomatedEmailSendBatch, Domain from app.types import CsvRow from tests.unit.helpers import build_model, create_model, override_settings def _make_event(bucket: str, key: str) -> dict: return { "Records": [ { "eventTime": "2024-01-01T00:00:00Z", "s3": { "bucket": {"name": bucket}, "object": {"key": key, "size": 100}, }, } ] } _CSV_FIELDS = [ "row_id", "automated_email_id", "automated_email_trigger_id", "timestamp", "is_doi_send", "confirmation_id", "fan_profile_id", "fan_email", "fan_first_name", "fan_last_name", "fan_country_code", ] def _make_csv(rows: list[CsvRow]) -> str: lines = [",".join(_CSV_FIELDS)] for row in rows: lines.append( ",".join( [ str(row.row_id), row.automated_email_id, row.automated_email_trigger_id, row.timestamp, str(row.is_doi_send).lower(), row.confirmation_id or "", row.fan_profile_id or "", row.fan_email, row.fan_first_name, row.fan_last_name, row.fan_country_code, ] ) ) return "\n".join(lines) def test_parse_csv_skips_malformed_row_id() -> None: csv_content = ( "row_id,automated_email_id,automated_email_trigger_id,timestamp,is_doi_send," "confirmation_id,fan_profile_id,fan_email,fan_first_name,fan_last_name,fan_country_code\n" "not_a_number,email_1,trigger_1,2024-01-01,false,,,,,,\n" "2,email_1,trigger_1,2024-01-01,false,,,fan@example.com,John,Doe,US\n" ) rows = _parse_csv(csv_content) assert len(rows) == 1 assert rows[0].row_id == 2 def test_parse_csv_skips_row_with_missing_column() -> None: csv_content = "row_id,automated_email_id\n1,email_1\n" rows = _parse_csv(csv_content) assert rows == [] @pytest.mark.db def test_handle( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model(AutomatedEmail, domain_id=domain.id) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) handle(_make_event("test-bucket", "path/batch_001.csv")) sendgrid_client_mock.send_mail.assert_called_once() @pytest.mark.db def test_handle_creates_batch_record( s3_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model(AutomatedEmail, domain_id=domain.id) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) handle(_make_event("test-bucket", "batch_002.csv")) batch = AutomatedEmailSendBatch.query.where( AutomatedEmailSendBatch.automated_email_id == automated_email.id, AutomatedEmailSendBatch.key == "batch_002.csv", ).one() assert batch.automated_email_id == automated_email.id assert batch.is_completed @pytest.mark.db def test_handle_updates_last_sent_at_after_send( s3_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model( AutomatedEmail, domain_id=domain.id, last_sent_at=None ) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) handle(_make_event("test-bucket", "last_sent_at.csv")) automated_email.refresh() assert automated_email.last_sent_at is not None @pytest.mark.db def test_handle_batch_already_completed_skips_send( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model(AutomatedEmail, domain_id=domain.id) create_model( AutomatedEmailSendBatch, key="already_done.csv", automated_email_id=automated_email.id, completed_at=timezone.now(), ) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) handle(_make_event("test-bucket", "already_done.csv")) sendgrid_client_mock.send_mail.assert_not_called() @pytest.mark.db def test_handle_automated_email_not_found( s3_client_mock: mock.MagicMock, ) -> None: csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id="nonexistent-id", fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content with pytest.raises(EmailSendError, match="AutomatedEmail (.*?) not found."): handle(_make_event("test-bucket", "path/no_email.csv")) @pytest.mark.db def test_handle_missing_sender_details( s3_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model( AutomatedEmail, domain_id=domain.id, sender_name=None ) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content with pytest.raises(EmailSendError, match="Sender details are missing."): handle(_make_event("test-bucket", "path/no_sender.csv")) @pytest.mark.db def test_handle_missing_content( s3_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model( AutomatedEmail, domain_id=domain.id, html_content=None ) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content with pytest.raises(EmailSendError, match="Email content is missing."): handle(_make_event("test-bucket", "path/no_content.csv")) @pytest.mark.db def test_handle_missing_domain( s3_client_mock: mock.MagicMock, ) -> None: automated_email = create_model(AutomatedEmail, domain_id=None) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content with pytest.raises(EmailSendError, match="Email domain is not set."): handle(_make_event("test-bucket", "path/no_domain.csv")) @pytest.mark.db def test_handle_resumes_from_last_sent_row_id( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model(AutomatedEmail, domain_id=domain.id) create_model( AutomatedEmailSendBatch, key="partial.csv", automated_email_id=automated_email.id, last_sent_row_id=1, ) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan1@sonymusic-pde.com", ), build_model( CsvRow, row_id=2, automated_email_id=automated_email.id, fan_email="fan2@sonymusic-pde.com", ), ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) handle(_make_event("test-bucket", "partial.csv")) # Only one send call (for row_id=2 only) sendgrid_client_mock.send_mail.assert_called_once() @pytest.mark.db def test_handle_all_rows_already_sent_marks_batch_completed( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model(AutomatedEmail, domain_id=domain.id) create_model( AutomatedEmailSendBatch, key="all_sent.csv", automated_email_id=automated_email.id, last_sent_row_id=2, ) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan1@sonymusic-pde.com", ), build_model( CsvRow, row_id=2, automated_email_id=automated_email.id, fan_email="fan2@sonymusic-pde.com", ), ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) handle(_make_event("test-bucket", "all_sent.csv")) sendgrid_client_mock.send_mail.assert_not_called() batch = AutomatedEmailSendBatch.query.where( AutomatedEmailSendBatch.automated_email_id == automated_email.id, AutomatedEmailSendBatch.key == "all_sent.csv", ).one() assert batch.is_completed @pytest.mark.db def test_handle_empty_csv( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ) -> None: s3_client_mock.get_object_content.return_value = ( "row_id,automated_email_id,automated_email_trigger_id,timestamp,is_doi_send," "confirmation_id,fan_profile_id,fan_email,fan_first_name,fan_last_name,fan_country_code\n" ) handle(_make_event("test-bucket", "empty.csv")) sendgrid_client_mock.send_mail.assert_not_called() @pytest.mark.db def test_handle_skip_sendgrid_send_by_special_tag( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model( AutomatedEmail, domain_id=domain.id, subject="#automationtest Subject" ) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@gmail.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) with override_settings(send_fansifter_only=False, environment="qa"): handle(_make_event("test-bucket", "skip_tag.csv")) sendgrid_client_mock.send_mail.assert_not_called() @pytest.mark.db def test_handle_sendgrid_error_does_not_update_batch( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model(AutomatedEmail, domain_id=domain.id) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) sendgrid_client_mock.send_mail.side_effect = SendGridClientError("SendGrid error") with pytest.raises(EmailSendError, match="Failed to send emails via SendGrid."): handle(_make_event("test-bucket", "sg_error.csv")) batch = AutomatedEmailSendBatch.query.where( AutomatedEmailSendBatch.automated_email_id == automated_email.id, AutomatedEmailSendBatch.key == "sg_error.csv", ).one() assert batch.last_sent_row_id == 0 assert not batch.is_completed @pytest.mark.db def test_handle_all_fans_filtered_out_still_completes_batch( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model(AutomatedEmail, domain_id=domain.id) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@gmail.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) handle(_make_event("test-bucket", "filtered.csv")) sendgrid_client_mock.send_mail.assert_not_called() batch = AutomatedEmailSendBatch.query.where( AutomatedEmailSendBatch.automated_email_id == automated_email.id, AutomatedEmailSendBatch.key == "filtered.csv", ).one() assert batch is not None assert batch.is_completed @pytest.mark.db def test_handle_multiple_automated_emails_in_csv( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email_1 = create_model(AutomatedEmail, domain_id=domain.id) automated_email_2 = create_model(AutomatedEmail, domain_id=domain.id) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email_1.id, fan_email="fan1@sonymusic-pde.com", ), build_model( CsvRow, row_id=2, automated_email_id=automated_email_2.id, fan_email="fan2@sonymusic-pde.com", ), ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) handle(_make_event("test-bucket", "multi.csv")) assert sendgrid_client_mock.send_mail.call_count == 2 @pytest.mark.db def test_handle_deduplication_skips_already_sent_fans( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model(AutomatedEmail, domain_id=domain.id) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ) ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) # First invocation — should send handle(_make_event("test-bucket", "dedup_first.csv")) assert sendgrid_client_mock.send_mail.call_count == 1 # Second invocation with a different key but same fan — should be skipped via Redis handle(_make_event("test-bucket", "dedup_second.csv")) assert sendgrid_client_mock.send_mail.call_count == 1 @pytest.mark.db def test_handle_prod_sends_to_all_domains( s3_client_mock: mock.MagicMock, sendgrid_client_mock: mock.MagicMock, ows_account_client_mock: mock.MagicMock, ) -> None: domain = create_model(Domain) automated_email = create_model(AutomatedEmail, domain_id=domain.id) csv_content = _make_csv( [ build_model( CsvRow, row_id=1, automated_email_id=automated_email.id, fan_email="fan@sonymusic-pde.com", ), build_model( CsvRow, row_id=2, automated_email_id=automated_email.id, fan_email="fan@gmail.com", ), ] ) s3_client_mock.get_object_content.return_value = csv_content ows_account_client_mock.get_vendor.return_value = mock.MagicMock( vendor_id=1, name="Test Vendor" ) with override_settings(send_fansifter_only=False, environment="prod"): handle(_make_event("test-bucket", "prod_all_domains.csv")) sendgrid_client_mock.send_mail.assert_called_once() to_fans = sendgrid_client_mock.send_mail.call_args.kwargs["to_fans"] assert sorted(fan.email for fan in to_fans) == [ "fan@gmail.com", "fan@sonymusic-pde.com", ]