from unittest import mock import faker import freezegun import pytest from dirty_equals import IsInt, IsList, IsNumber, IsPartialDataclass from fansifter_common.utils import timezone from app.adapters.db import Database from app.config import Settings from app.enums import CampaignCancelReason, CampaignStatus from app.handler import DispatchCampaignsHandler from app.models import ( AudienceEmailFan, Campaign, CampaignBatch, DeliveryQuota, DeliveryQuotaDefault, DeliveryQuotaGrowthRate, Domain, FanPersonalDataDbt, Profile, ) from app.types import BatchSend from tests.unit.types import CreateModel class TestDispatchCampaignsHandler: @pytest.mark.db def test_get_delivery_quota_initial( self, handler: DispatchCampaignsHandler ) -> None: delivery_quota = handler.get_delivery_quota( domain_id="example.com", provider="example.com" ) assert delivery_quota.last_hourly_quota is None assert delivery_quota.last_hour_sent_emails == 0 assert delivery_quota.last_sent_at is None @pytest.mark.db @freezegun.freeze_time("2025-10-01 12:00:00") def test_get_delivery_quota( self, handler: DispatchCampaignsHandler, create_model: CreateModel ) -> None: delivery_quota = create_model( DeliveryQuota, last_sent_at=timezone.now(), last_hourly_quota=100, last_hour_sent_emails=0, ) result = handler.get_delivery_quota( domain_id=delivery_quota.domain_id, provider=delivery_quota.provider ) assert result == delivery_quota @pytest.mark.db @freezegun.freeze_time("2025-10-01 15:00:00") def test_get_delivery_quota_default( self, handler: DispatchCampaignsHandler, create_model: CreateModel ) -> None: delivery_quota_default = create_model( DeliveryQuotaDefault, last_hourly_quota=100, ) delivery_quota = handler.get_delivery_quota( domain_id=delivery_quota_default.domain_id, provider=delivery_quota_default.provider, ) assert ( delivery_quota.last_hourly_quota == delivery_quota_default.last_hourly_quota ) assert delivery_quota.last_hour_sent_emails == 0 assert delivery_quota.last_sent_at is None @pytest.mark.db def test_get_growth_rate( self, handler: DispatchCampaignsHandler, create_model: CreateModel, ) -> None: """Should return growth_rate when record exists for given domain and provider.""" delivery_quota_growth_rate = create_model( DeliveryQuotaGrowthRate, growth_rate=1.5, ) growth_rate = handler.get_growth_rate( domain_id=delivery_quota_growth_rate.domain_id, provider=delivery_quota_growth_rate.provider, ) assert growth_rate == delivery_quota_growth_rate.growth_rate @pytest.mark.db def test_get_growth_rate_not_found( self, handler: DispatchCampaignsHandler, ) -> None: result = handler.get_growth_rate(domain_id="unknown.com", provider="yahoo") assert result is None @pytest.mark.db def test_prepare_campaign_for_dispatch_skips_if_already_prepared( self, db: Database, handler: DispatchCampaignsHandler, create_model: CreateModel, ) -> None: """ Should skip preparation if campaign is already prepared. """ campaign = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=timezone.now(), ) create_model(CampaignBatch, campaign_id=campaign.id, completed_at=None) handler.prepare_campaign_for_dispatch(campaign) db.session.refresh(campaign) # Should return None and not modify campaign assert campaign.status == CampaignStatus.IN_PROGRESS assert campaign.prepared_at is not None @pytest.mark.db def test_prepare_campaign_for_dispatch_skips_if_already_prepared_and_completed( self, db: Database, handler: DispatchCampaignsHandler, create_model: CreateModel, ) -> None: """ Should skip preparation if campaign is already prepared and completed. """ campaign = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=timezone.now(), ) create_model( CampaignBatch, campaign_id=campaign.id, completed_at=timezone.now() ) handler.prepare_campaign_for_dispatch(campaign) db.session.refresh(campaign) # Should return None and not modify campaign assert campaign.status == CampaignStatus.SENT assert campaign.prepared_at is not None @pytest.mark.db def test_prepare_campaign_for_dispatch_cancels_if_no_recipients( self, handler: DispatchCampaignsHandler, create_model: CreateModel, ows_dmp_client_mock: mock.Mock, ) -> None: """ Should cancel campaign when no recipients found. """ campaign = create_model( Campaign, status=CampaignStatus.SCHEDULED, prepared_at=None, ) ows_dmp_client_mock.upsert_audience_fans.return_value = 1 handler.prepare_campaign_for_dispatch(campaign) assert campaign.status == CampaignStatus.CANCELLED assert campaign.cancel_reason == CampaignCancelReason.NO_FANS assert campaign.cancelled_at is not None @pytest.mark.db def test_prepare_campaign_for_dispatch_marks_in_progress_when_recipients_exist( self, handler: DispatchCampaignsHandler, create_model: CreateModel, faker: faker.Faker, ows_dmp_client_mock: mock.Mock, ) -> None: """ Should mark campaign IN_PROGRESS, set prepared, and save when recipients exist. """ audience_id = faker.uuid4() domain = create_model(Domain, domain="sonymusic-pde.com") profile = create_model(Profile, email="fan@sonymusic-pde.com") personal_data = create_model(FanPersonalDataDbt, fan_email=profile.email) create_model( AudienceEmailFan, audience_id=audience_id, fan_id=personal_data.fan_id ) campaign = create_model( Campaign, status=CampaignStatus.SCHEDULED, domain_id=domain.id, audience_id=audience_id, prepared_at=None, ) ows_dmp_client_mock.upsert_audience_fans.return_value = 1 handler.prepare_campaign_for_dispatch(campaign) assert campaign.status == CampaignStatus.IN_PROGRESS assert campaign.recipient_count == 1 assert campaign.is_prepared @pytest.mark.db def test_plan_batch_sends_returns_empty_when_no_active_batches( self, handler: DispatchCampaignsHandler, ) -> None: """ Should return an empty list when there are no active batches. """ result = handler.plan_batch_sends(dispatch_time=timezone.now()) assert result == [] @pytest.mark.db def test_plan_batch_sends_allocates_quota_evenly_across_batches( self, handler: DispatchCampaignsHandler, create_model: CreateModel, ) -> None: """ Should distribute available quota evenly across all batches in a group. """ domain = create_model(Domain, domain="sonymusic-pde.com") provider_1 = "google" provider_2 = "microsoft" campaign = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=timezone.now(), cancelled_at=None, deleted_at=None, ) batch_1 = create_model( CampaignBatch, campaign_id=campaign.id, domain_id=domain.id, provider=provider_1, batch_size=100, batch_offset=0, last_sent_at=None, last_hourly_quota=None, last_hour_sent_emails=0, ) batch_2 = create_model( CampaignBatch, campaign_id=campaign.id, domain_id=domain.id, provider=provider_2, batch_size=100, batch_offset=0, last_sent_at=None, last_hourly_quota=None, last_hour_sent_emails=0, ) result = handler.plan_batch_sends(dispatch_time=timezone.now()) assert result == IsList( BatchSend( batch_id=batch_1.id, batch_size=batch_1.batch_size, batch_offset=batch_1.batch_offset, campaign_id=campaign.id, provider=provider_1, current_hourly_quota=IsNumber(approx=20.0, delta=0.1), # type: ignore emails_to_send=1, ), BatchSend( batch_id=batch_2.id, batch_size=batch_2.batch_size, batch_offset=batch_2.batch_offset, campaign_id=campaign.id, provider=provider_2, current_hourly_quota=IsNumber(approx=20.0, delta=0.1), # type: ignore emails_to_send=1, ), check_order=False, ) @pytest.mark.db @freezegun.freeze_time("2021-05-01") def test_plan_batch_sends_not_available_quota( self, handler: DispatchCampaignsHandler, create_model: CreateModel, ) -> None: domain = create_model(Domain) provider = "google" create_model( DeliveryQuota, domain_id=domain.id, provider=provider, last_sent_at=timezone.now(), last_hourly_quota=20.0, last_hour_sent_emails=20, ) campaign = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=timezone.now(), cancelled_at=None, deleted_at=None, ) create_model( CampaignBatch, campaign_id=campaign.id, domain_id=domain.id, provider=provider, batch_size=100, batch_offset=0, ) result = handler.plan_batch_sends(dispatch_time=timezone.now()) assert result == [] @pytest.mark.db def test_plan_batch_sends_no_remaining_quota( self, handler: DispatchCampaignsHandler, create_model: CreateModel, ) -> None: domain = create_model(Domain, domain="sonymusic-pde.com") provider = "google" campaign_1 = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=timezone.now(), ) campaign_2 = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=timezone.now(), ) create_model( CampaignBatch, campaign_id=campaign_1.id, domain_id=domain.id, provider=provider, batch_size=20, batch_offset=0, last_sent_at=None, last_hourly_quota=None, last_hour_sent_emails=0, ) create_model( CampaignBatch, campaign_id=campaign_2.id, domain_id=domain.id, provider=provider, batch_size=20, batch_offset=0, last_sent_at=None, last_hourly_quota=None, last_hour_sent_emails=0, ) result = handler.plan_batch_sends(dispatch_time=timezone.now()) assert result == IsList( IsPartialDataclass( provider=provider, current_hourly_quota=IsNumber(approx=20.0, delta=0.1), # type: ignore emails_to_send=1, ), ) @pytest.mark.db @freezegun.freeze_time("2025-10-01 12:10:00") def test_plan_batch_sends_default_quota( self, handler: DispatchCampaignsHandler, create_model: CreateModel, ) -> None: domain = create_model(Domain, domain="sonymusic-pde.com") provider = "google" campaign = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=timezone.now(), ) create_model( CampaignBatch, campaign_id=campaign.id, domain_id=domain.id, provider=provider, batch_size=1, batch_offset=0, last_sent_at=None, last_hourly_quota=None, last_hour_sent_emails=0, ) create_model( DeliveryQuotaDefault, domain_id=domain.id, provider=provider, last_hourly_quota=20.0, ) result = handler.plan_batch_sends(dispatch_time=timezone.now()) assert result == IsList( IsPartialDataclass( provider=provider, current_hourly_quota=IsNumber(approx=20.0, delta=0.1), # type: ignore emails_to_send=1, ), ) @pytest.mark.db def test_handle_no_ready_campaigns(self, handler: DispatchCampaignsHandler) -> None: batch_sends = handler.handle() assert batch_sends == [] @pytest.mark.db @freezegun.freeze_time("2025-10-01 12:10:00") def test_handle_with_ready_campaign( self, handler: DispatchCampaignsHandler, create_model: CreateModel, faker: faker.Faker, lambda_client_mock: mock.MagicMock, ows_dmp_client_mock: mock.MagicMock, redis_client_mock: mock.MagicMock, settings: Settings, ) -> None: dispatch_time = timezone.now() audience_id = faker.uuid4() domain = create_model(Domain) profile = create_model(Profile, email="user@sonymusic-pde.com") personal_data = create_model(FanPersonalDataDbt, fan_email=profile.email) create_model( AudienceEmailFan, audience_id=audience_id, fan_id=personal_data.fan_id, ) campaign = create_model( Campaign, domain_id=domain.id, audience_id=audience_id, status=CampaignStatus.SCHEDULED, send_at=timezone.now(), ) ows_dmp_client_mock.upsert_audience_fans.return_value = 1 redis_client_mock.exists.return_value = False batch_sends = handler.handle() assert len(batch_sends) == 1 batch_send = batch_sends[0] assert batch_send.batch_id == IsInt() assert batch_send.campaign_id == campaign.id assert batch_send.current_hourly_quota == IsNumber(approx=20.0, delta=0.1) assert batch_send.emails_to_send == 1 assert campaign.is_in_progress ows_dmp_client_mock.upsert_audience_fans.assert_called_once_with( audience_id=audience_id ) lambda_client_mock.invoke.assert_called_once_with( function_name=settings.sender_lambda_function_name, invocation_type="Event", data={ "batch_id": batch_send.batch_id, "campaign_id": batch_send.campaign_id, "emails_to_send": batch_send.emails_to_send, "current_hourly_quota": batch_send.current_hourly_quota, "dispatch_time": dispatch_time.isoformat(), }, ) @pytest.mark.db @freezegun.freeze_time("2025-10-01 12:10:00") def test_handle_skips_batch_when_lock_exists( self, handler: DispatchCampaignsHandler, create_model: CreateModel, faker: faker.Faker, lambda_client_mock: mock.MagicMock, ows_dmp_client_mock: mock.MagicMock, redis_client_mock: mock.MagicMock, ) -> None: audience_id = faker.uuid4() domain = create_model(Domain) profile = create_model(Profile, email="user@sonymusic-pde.com") personal_data = create_model(FanPersonalDataDbt, fan_email=profile.email) create_model( AudienceEmailFan, audience_id=audience_id, fan_id=personal_data.fan_id, ) create_model( Campaign, domain_id=domain.id, audience_id=audience_id, status=CampaignStatus.SCHEDULED, send_at=timezone.now(), ) ows_dmp_client_mock.upsert_audience_fans.return_value = 1 redis_client_mock.exists.return_value = True result = handler.handle() assert len(result) == 1 lambda_client_mock.invoke.assert_not_called() @pytest.mark.db @freezegun.freeze_time("2025-10-01 12:10:00") def test_handle_invokes_lambda_when_no_lock_exists( self, handler: DispatchCampaignsHandler, create_model: CreateModel, faker: faker.Faker, lambda_client_mock: mock.MagicMock, ows_dmp_client_mock: mock.MagicMock, redis_client_mock: mock.MagicMock, ) -> None: audience_id = faker.uuid4() domain = create_model(Domain) profile = create_model(Profile, email="user@sonymusic-pde.com") personal_data = create_model(FanPersonalDataDbt, fan_email=profile.email) create_model( AudienceEmailFan, audience_id=audience_id, fan_id=personal_data.fan_id, ) create_model( Campaign, domain_id=domain.id, audience_id=audience_id, status=CampaignStatus.SCHEDULED, send_at=timezone.now(), ) ows_dmp_client_mock.upsert_audience_fans.return_value = 1 redis_client_mock.exists.return_value = False # No lock key exists batch_sends = handler.handle() assert len(batch_sends) == 1 lambda_client_mock.invoke.assert_called_once()