from datetime import timedelta import pytest from fansifter_common.utils import timezone from app.enums import CampaignCancelReason, CampaignStatus from app.models import AudienceTextFan, BatchRecipient, Campaign, CampaignBatch from tests.unit.helpers import build_model, create_model class TestCampaign: def test_is_scheduled(self) -> None: campaign = build_model(Campaign, status=CampaignStatus.SCHEDULED) assert campaign.is_scheduled assert not campaign.is_in_progress def test_is_in_progress(self) -> None: campaign = build_model(Campaign, status=CampaignStatus.IN_PROGRESS) assert campaign.is_in_progress assert not campaign.is_scheduled def test_is_sent(self) -> None: campaign = build_model(Campaign, status=CampaignStatus.SENT) assert campaign.is_sent def test_is_cancelled(self) -> None: campaign = build_model(Campaign, status=CampaignStatus.CANCELLED) assert campaign.is_cancelled def test_is_deleted(self) -> None: assert build_model(Campaign, deleted_at=timezone.now()).is_deleted assert not build_model(Campaign, deleted_at=None).is_deleted def test_is_prepared(self) -> None: assert build_model(Campaign, prepared_at=timezone.now()).is_prepared assert not build_model(Campaign, prepared_at=None).is_prepared def test_cancel_sets_status_reason_and_timestamp(self) -> None: campaign = build_model(Campaign, status=CampaignStatus.SCHEDULED) campaign.cancel(CampaignCancelReason.NO_FANS) assert campaign.status == CampaignStatus.CANCELLED assert campaign.cancel_reason == CampaignCancelReason.NO_FANS assert campaign.cancelled_at is not None def test_is_ready_to_dispatch_when_scheduled_and_send_at_passed(self) -> None: campaign = build_model( Campaign, status=CampaignStatus.SCHEDULED, send_at=timezone.now() - timedelta(minutes=5), cancelled_at=None, deleted_at=None, ) assert campaign.is_ready_to_dispatch() def test_is_ready_to_dispatch_when_in_progress_and_send_at_passed(self) -> None: campaign = build_model( Campaign, status=CampaignStatus.IN_PROGRESS, send_at=timezone.now() - timedelta(minutes=5), cancelled_at=None, deleted_at=None, ) assert campaign.is_ready_to_dispatch() def test_is_ready_to_dispatch_false_when_send_at_in_future(self) -> None: campaign = build_model( Campaign, status=CampaignStatus.SCHEDULED, send_at=timezone.now() + timedelta(minutes=5), ) assert not campaign.is_ready_to_dispatch() def test_is_ready_to_dispatch_false_when_send_at_missing(self) -> None: campaign = build_model(Campaign, status=CampaignStatus.SCHEDULED, send_at=None) assert not campaign.is_ready_to_dispatch() def test_is_ready_to_dispatch_false_when_already_sent(self) -> None: campaign = build_model( Campaign, status=CampaignStatus.SENT, send_at=timezone.now() - timedelta(minutes=5), ) assert not campaign.is_ready_to_dispatch() def test_is_ready_to_dispatch_false_when_cancelled(self) -> None: campaign = build_model( Campaign, status=CampaignStatus.CANCELLED, send_at=timezone.now() - timedelta(minutes=5), cancelled_at=timezone.now(), ) assert not campaign.is_ready_to_dispatch() def test_is_ready_to_dispatch_false_when_deleted(self) -> None: campaign = build_model( Campaign, status=CampaignStatus.SCHEDULED, send_at=timezone.now() - timedelta(minutes=5), deleted_at=timezone.now(), ) assert not campaign.is_ready_to_dispatch() def test_is_ready_to_dispatch_false_when_draft(self) -> None: campaign = build_model( Campaign, status=CampaignStatus.DRAFT, send_at=timezone.now() - timedelta(minutes=5), ) assert not campaign.is_ready_to_dispatch() class TestCampaignBatch: def test_is_active_when_not_completed_not_cancelled_and_not_full(self) -> None: batch = build_model( CampaignBatch, batch_size=100, batch_offset=10, completed_at=None, cancelled_at=None, ) assert batch.is_active def test_is_active_false_when_completed(self) -> None: batch = build_model( CampaignBatch, batch_size=100, batch_offset=10, completed_at=timezone.now(), cancelled_at=None, ) assert not batch.is_active def test_is_active_false_when_cancelled(self) -> None: batch = build_model( CampaignBatch, batch_size=100, batch_offset=10, completed_at=None, cancelled_at=timezone.now(), ) assert not batch.is_active def test_is_active_false_when_offset_reached_batch_size(self) -> None: batch = build_model( CampaignBatch, batch_size=100, batch_offset=100, completed_at=None, cancelled_at=None, ) assert not batch.is_active def test_is_completed(self) -> None: assert build_model(CampaignBatch, completed_at=timezone.now()).is_completed assert not build_model(CampaignBatch, completed_at=None).is_completed class TestCampaignQuery: @pytest.mark.db def test_find_ready_to_dispatch_returns_only_ready_campaigns(self) -> None: ready_scheduled = create_model( Campaign, status=CampaignStatus.SCHEDULED, send_at=timezone.now() - timedelta(minutes=5), cancelled_at=None, deleted_at=None, ) ready_in_progress = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, send_at=timezone.now() - timedelta(minutes=5), cancelled_at=None, deleted_at=None, ) create_model( Campaign, status=CampaignStatus.SCHEDULED, send_at=timezone.now() + timedelta(minutes=5), ) create_model( Campaign, status=CampaignStatus.SENT, send_at=timezone.now() - timedelta(minutes=5), ) create_model(Campaign, status=CampaignStatus.DRAFT, send_at=None) result = Campaign.query.find_ready_to_dispatch() assert {campaign.id for campaign in result} == { ready_scheduled.id, ready_in_progress.id, } @pytest.mark.db def test_find_ready_to_dispatch_returns_empty_when_none_ready(self) -> None: create_model( Campaign, status=CampaignStatus.SCHEDULED, send_at=timezone.now() + timedelta(minutes=5), ) create_model( Campaign, status=CampaignStatus.SENT, send_at=timezone.now() - timedelta(minutes=5), ) assert Campaign.query.find_ready_to_dispatch() == [] class TestCampaignBatchQuery: @pytest.mark.db def test_find_active_returns_only_active_batches_of_in_progress_campaigns( self, ) -> None: campaign_active = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=timezone.now(), cancelled_at=None, deleted_at=None, ) campaign_sent = create_model( Campaign, status=CampaignStatus.SENT, prepared_at=timezone.now(), ) campaign_cancelled = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=timezone.now(), cancelled_at=timezone.now(), ) campaign_unprepared = create_model( Campaign, status=CampaignStatus.IN_PROGRESS, prepared_at=None, ) active_batch = create_model( CampaignBatch, campaign_id=campaign_active.id, batch_size=10, batch_offset=0 ) create_model( CampaignBatch, campaign_id=campaign_active.id, batch_size=10, batch_offset=10, completed_at=timezone.now(), ) create_model( CampaignBatch, campaign_id=campaign_active.id, batch_size=10, batch_offset=0, cancelled_at=timezone.now(), ) # Fully sent but not yet marked completed -- must not be re-dispatched. create_model( CampaignBatch, campaign_id=campaign_active.id, batch_size=10, batch_offset=10, ) create_model( CampaignBatch, campaign_id=campaign_sent.id, batch_size=10, batch_offset=0 ) create_model( CampaignBatch, campaign_id=campaign_cancelled.id, batch_size=10, batch_offset=0, ) create_model( CampaignBatch, campaign_id=campaign_unprepared.id, batch_size=10, batch_offset=0, ) result = CampaignBatch.query.active().all() assert [batch.id for batch in result] == [active_batch.id] @pytest.mark.db def test_find_active_returns_empty_when_no_active_batches(self) -> None: campaign = create_model( Campaign, status=CampaignStatus.SENT, prepared_at=timezone.now(), ) create_model(CampaignBatch, campaign_id=campaign.id) assert CampaignBatch.query.active().all() == [] class TestBatchRecipientQuery: @pytest.mark.db def test_bulk_create_from_audience_copies_matching_fans_only(self) -> None: audience_id = "audience-1" campaign = create_model(Campaign) batch = create_model(CampaignBatch, campaign_id=campaign.id) fan_1 = create_model( AudienceTextFan, audience_id=audience_id, fan_country="US", fan_state="NY", channel="SMS", ) fan_2 = create_model( AudienceTextFan, audience_id=audience_id, fan_country="US", fan_state="NY", channel="SMS", ) # different state, channel and audience -- none of these should match create_model( AudienceTextFan, audience_id=audience_id, fan_country="US", fan_state="CA", channel="SMS", ) create_model( AudienceTextFan, audience_id=audience_id, fan_country="US", fan_state="NY", channel="WHATSAPP", ) create_model( AudienceTextFan, audience_id="other-audience", fan_country="US", fan_state="NY", channel="SMS", ) BatchRecipient.query.bulk_create_from_audience( batch_id=batch.id, audience_id=audience_id, channel="SMS", country_code="US", state_province="NY", ) recipients = BatchRecipient.query.where( BatchRecipient.batch_id == batch.id ).all() assert {r.fan_id for r in recipients} == {fan_1.fan_id, fan_2.fan_id} @pytest.mark.db def test_bulk_create_from_audience_matches_null_state(self) -> None: audience_id = "audience-1" campaign = create_model(Campaign) batch = create_model(CampaignBatch, campaign_id=campaign.id) fan = create_model( AudienceTextFan, audience_id=audience_id, fan_country="GB", fan_state=None, channel="SMS", ) BatchRecipient.query.bulk_create_from_audience( batch_id=batch.id, audience_id=audience_id, channel="SMS", country_code="GB", state_province=None, ) recipients = BatchRecipient.query.where( BatchRecipient.batch_id == batch.id ).all() assert [r.fan_id for r in recipients] == [fan.fan_id] @pytest.mark.db def test_bulk_create_from_audience_does_not_leak_across_batches(self) -> None: audience_id = "audience-1" campaign = create_model(Campaign) batch_1 = create_model(CampaignBatch, campaign_id=campaign.id) batch_2 = create_model(CampaignBatch, campaign_id=campaign.id) create_model( AudienceTextFan, audience_id=audience_id, fan_country="US", fan_state="NY", channel="SMS", ) BatchRecipient.query.bulk_create_from_audience( batch_id=batch_1.id, audience_id=audience_id, channel="SMS", country_code="US", state_province="NY", ) assert ( len(BatchRecipient.query.where(BatchRecipient.batch_id == batch_1.id).all()) == 1 ) assert ( BatchRecipient.query.where(BatchRecipient.batch_id == batch_2.id).all() == [] ) class TestAudienceTextFanQuery: @pytest.mark.db def test_count_by_group_groups_by_country_and_state(self) -> None: audience_id = "audience-1" create_model( AudienceTextFan, audience_id=audience_id, fan_country="US", fan_state="NY", channel="SMS", ) create_model( AudienceTextFan, audience_id=audience_id, fan_country="US", fan_state="NY", channel="SMS", ) create_model( AudienceTextFan, audience_id=audience_id, fan_country="US", fan_state="CA", channel="SMS", ) # different channel and audience -- excluded from the count create_model( AudienceTextFan, audience_id=audience_id, fan_country="US", fan_state="NY", channel="WHATSAPP", ) create_model( AudienceTextFan, audience_id="other-audience", fan_country="US", fan_state="NY", channel="SMS", ) groups = AudienceTextFan.query.count_by_group( audience_id=audience_id, channel="SMS" ) assert {tuple(group) for group in groups} == { ("US", "NY", 2), ("US", "CA", 1), } @pytest.mark.db def test_count_by_group_returns_empty_for_unknown_audience(self) -> None: groups = AudienceTextFan.query.count_by_group( audience_id="nonexistent", channel="SMS" ) assert list(groups) == []