import base64 import json import urllib.parse from collections.abc import Iterator from datetime import timedelta from unittest import mock from unittest.mock import Mock import faker import pytest from anydi import Container from dirty_equals import IsNumeric, IsStr from fansifter_common.adapters.graphql_router import GlobalParticipant from fansifter_common.adapters.ows_account import Vendor from fansifter_common.auth.account import Account from fansifter_common.core.enums import Brand from fansifter_common.utils import timezone from starlette.testclient import TestClient from email_campaigns.audiences.enums import AudienceTarget from email_campaigns.audiences.models import Audience from email_campaigns.campaigns.enums import EmailCampaignStatus from email_campaigns.campaigns.handlers import ( AutosaveCampaignHandler, AutosaveCampaignV2Handler, ) from email_campaigns.campaigns.models import CampaignBatch, DeliveryQuota, EmailCampaign from email_campaigns.emails.models import ( EmailAnalyticsBenchmarkByAccountArtistV2Dbt, EmailAnalyticsBenchmarkByLinktV3Dbt, EmailAnalyticsV3Dbt, EmailDomain, ShopifyEmailOrdersDbt, ShopifyEmailProductsDbt, ShopifyProductTypesDbt, ) from tests.unit.equals import IsISODatetime, IsISODatetimeOrNone from tests.unit.types import BuildModel, CreateModel, CreatePgModel @pytest.mark.db def test_create_email_campaign_draft( client: TestClient, identity_id: str, faker: faker.Faker, create_model: CreateModel, build_model: BuildModel, ows_account_client_mock: mock.MagicMock, graphql_router_client_mock: mock.MagicMock, account: Account, ) -> None: vendor = build_model(Vendor, vendor_id=account.vendor_id, company_brand=Brand.SME) global_participant = GlobalParticipant(id=faker.uuid4(), name="Artist Name") email_domain = create_model( EmailDomain, brand=vendor.brand, domain="example.com", vendor_id=account.vendor_id, subaccount_id=account.subaccount_id, ) ows_account_client_mock.get_vendor.return_value = vendor graphql_router_client_mock.get_global_participant_by_gp_id.return_value = ( global_participant ) response = client.post( "/campaigns", json={ "name": "Test Campaign", "vendorId": account.vendor_id, "subaccountId": account.subaccount_id, "fanDataListId": { "id": global_participant.id, "type": "ARTIST", }, }, ) assert response.status_code == 201 assert response.json() == { "id": IsStr(), "name": "Test Campaign", "vendorId": account.vendor_id, "subaccountId": account.subaccount_id, "fanDataListId": { "id": global_participant.id, "type": "ARTIST", }, "audienceId": None, "status": "DRAFT", "emailDomain": { "id": email_domain.id, "domain": "example.com", "brand": Brand.SME, }, "emailUsername": "artistname", "previewText": None, "senderName": "Artist Name", "sendAt": None, "sendAtTimezone": None, "subject": None, "recipientsCount": 0, "cancelReason": None, "cancelledAt": None, "createdAt": IsStr(), "createdBy": identity_id, "updatedAt": IsStr(), "hasUnappliedChanges": False, "updatedBy": identity_id, "analytics": None, "audience": None, } @pytest.mark.db def test_create_email_campaign_draft_not_unique_name( client: TestClient, create_model: CreateModel ) -> None: campaign = create_model(EmailCampaign, name="Test Campaign") response = client.post( "/campaigns", json={ "name": "test campaign", "vendorId": campaign.vendor_id, "subaccountId": campaign.subaccount_id, "fanDataListId": { "id": campaign.global_participant_id, "type": "ARTIST", }, }, ) assert response.status_code == 400 assert response.json() == { "code": "email_campaign_unique_name", "message": "Email campaign with this name already exists", } @pytest.mark.db def test_get_email_campaigns( client: TestClient, create_model: CreateModel, account: Account ) -> None: email_domain = create_model(EmailDomain, vendor_id=None, subaccount_id=None) campaign = create_model( EmailCampaign, email_domain=email_domain, custom_list_id=None, vendor_id=account.vendor_id, subaccount_id=account.subaccount_id, ) response = client.post("/campaigns/list", json={}) assert response.status_code == 200 assert response.json() == { "total": 1, "items": [ { "id": campaign.id, "name": campaign.name, "vendorId": campaign.vendor_id, "subaccountId": campaign.subaccount_id, "fanDataListId": { "id": campaign.global_participant_id, "type": "ARTIST", }, "audienceId": campaign.audience_id, "status": campaign.status, "emailDomain": { "id": email_domain.id, "domain": email_domain.domain, "brand": email_domain.brand, }, "emailUsername": campaign.email_username, "previewText": campaign.preview_text, "senderName": campaign.sender_name, "sendAt": IsISODatetimeOrNone(campaign.send_at), "sendAtTimezone": campaign.send_at_timezone, "recipientsCount": campaign.recipients_count, "subject": campaign.subject, "cancelReason": campaign.cancel_reason, "cancelledAt": IsISODatetimeOrNone(campaign.cancelled_at), "createdAt": IsISODatetime(campaign.created_at), "createdBy": campaign.created_by, "updatedAt": IsISODatetime(campaign.updated_at), "hasUnappliedChanges": campaign.has_unapplied_changes, "updatedBy": campaign.updated_by, "analytics": None, "audience": None, } ], "limit": 50, "offset": 0, } @pytest.mark.db def test_get_email_campaigns_with_analytics( client: TestClient, create_model: CreateModel, create_pg_model: CreatePgModel, account: Account, ) -> None: email_domain = create_model(EmailDomain, vendor_id=None, subaccount_id=None) global_participant_id = "123" audience = create_pg_model( Audience, target=AudienceTarget.EMAIL, vendor_id=account.vendor_id, subaccount_id=account.subaccount_id, fan_count=100, ) campaign = create_model( EmailCampaign, email_domain=email_domain, global_participant_id=global_participant_id, vendor_id=account.vendor_id, subaccount_id=account.subaccount_id, audience_id=audience.id, custom_list_id=None, ) create_model( EmailAnalyticsV3Dbt, email_id=campaign.id, email_type=campaign.email_type, ) create_model( EmailAnalyticsBenchmarkByAccountArtistV2Dbt, global_participant_id=campaign.global_participant_id, custom_list_id=campaign.custom_list_id, vendor_id=account.vendor_id, subaccount_id=account.subaccount_id, email_type=campaign.email_type, ) response = client.post("/campaigns/list", json={}) assert response.status_code == 200 assert response.json() == { "total": 1, "items": [ { "id": campaign.id, "name": campaign.name, "vendorId": campaign.vendor_id, "subaccountId": campaign.subaccount_id, "fanDataListId": { "id": campaign.global_participant_id, "type": "ARTIST", }, "audienceId": campaign.audience_id, "status": campaign.status, "emailDomain": { "id": email_domain.id, "domain": email_domain.domain, "brand": email_domain.brand, }, "emailUsername": campaign.email_username, "previewText": campaign.preview_text, "senderName": campaign.sender_name, "sendAt": IsISODatetimeOrNone(campaign.send_at), "sendAtTimezone": campaign.send_at_timezone, "recipientsCount": campaign.recipients_count, "subject": campaign.subject, "cancelReason": campaign.cancel_reason, "cancelledAt": IsISODatetimeOrNone(campaign.cancelled_at), "createdAt": IsISODatetime(campaign.created_at), "createdBy": campaign.created_by, "updatedAt": IsISODatetime(campaign.updated_at), "hasUnappliedChanges": campaign.has_unapplied_changes, "updatedBy": campaign.updated_by, "audience": { "id": audience.id, "name": audience.name, "fanCount": audience.fan_count, "isArchived": audience.is_archived, "target": audience.target, }, "analytics": { "bounceRate": IsNumeric(), "bounceRateChange": IsNumeric(), "bounces": IsNumeric(), "clickThroughRate": IsNumeric(), "clickThroughRateChange": IsNumeric(), "clickToOpenRate": IsNumeric(), "clickToOpenRateChange": IsNumeric(), "clicks": IsNumeric(), "delivered": IsNumeric(), "deliveredRate": IsNumeric(), "deliveredRateChange": IsNumeric(), "openRate": IsNumeric(), "openRateChange": IsNumeric(), "opens": IsNumeric(), "sends": IsNumeric(), "spamComplaints": IsNumeric(), "spamComplaintsRate": IsNumeric(), "spamComplaintsRateChange": IsNumeric(), "uniqueClicks": IsNumeric(), "uniqueClicksChange": IsNumeric(), "uniqueClicksDropoff": IsNumeric(), "uniqueOpens": IsNumeric(), "uniqueOpensDropoff": IsNumeric(), "unsubscribeRate": IsNumeric(), "unsubscribeRateChange": IsNumeric(), "unsubscribes": IsNumeric(), }, } ], "limit": 50, "offset": 0, } @pytest.mark.db def test_search_campaigns( client: TestClient, create_model: CreateModel, account: Account, faker: faker.Faker ) -> None: global_participant_id = faker.uuid4() email_domain = create_model(EmailDomain, vendor_id=None, subaccount_id=None) campaign = create_model( EmailCampaign, email_domain=email_domain, custom_list_id=None, vendor_id=account.vendor_id, subaccount_id=account.subaccount_id, global_participant_id=global_participant_id, ) response = client.post( "/campaigns/search", json={ "campaignIds": [campaign.id], "fanDataListIds": [ { "type": "ARTIST", "id": global_participant_id, } ], }, ) assert response.status_code == 200 assert response.json() == [ { "id": campaign.id, "name": campaign.name, } ] @pytest.mark.db def test_get_email_campaign( client: TestClient, create_model: CreateModel, create_pg_model: CreatePgModel, account: Account, faker: faker.Faker, ) -> None: email_domain = create_model(EmailDomain) audience = create_pg_model( Audience, target=AudienceTarget.EMAIL, vendor_id=account.vendor_id, subaccount_id=account.subaccount_id, fan_count=100, ) campaign = create_model( EmailCampaign, sender_name=faker.name(), subject=faker.sentence(), audience_id=audience.id, preview_text=faker.sentence(), email_domain=email_domain, email_username=faker.user_name(), send_at=faker.past_datetime(tzinfo=timezone.UTC), custom_list_id=None, ) response = client.get(f"/campaigns/{campaign.id}") assert response.status_code == 200 assert response.json() == { "id": campaign.id, "name": campaign.name, "vendorId": campaign.vendor_id, "subaccountId": campaign.subaccount_id, "fanDataListId": { "id": campaign.global_participant_id, "type": "ARTIST", }, "audienceId": campaign.audience_id, "audience": { "id": audience.id, "name": audience.name, "fanCount": audience.fan_count, "isArchived": audience.is_archived, "target": audience.target, }, "status": campaign.status, "emailDomain": { "id": email_domain.id, "domain": email_domain.domain, "brand": email_domain.brand, }, "emailUsername": campaign.email_username, "previewText": campaign.preview_text, "senderName": campaign.sender_name, "sendAt": IsISODatetimeOrNone(campaign.send_at), "sendAtTimezone": campaign.send_at_timezone, "recipientsCount": campaign.recipients_count, "subject": campaign.subject, "cancelReason": campaign.cancel_reason, "cancelledAt": IsISODatetimeOrNone(campaign.cancelled_at), "createdAt": IsISODatetime(campaign.created_at), "createdBy": campaign.created_by, "updatedAt": IsISODatetime(campaign.updated_at), "hasUnappliedChanges": campaign.has_unapplied_changes, "updatedBy": campaign.updated_by, "analytics": None, } @pytest.mark.db def test_update_email_campaign( client: TestClient, build_model: BuildModel, create_model: CreateModel, create_pg_model: CreatePgModel, faker: faker.Faker, ows_account_client_mock: mock.MagicMock, account: Account, ) -> None: vendor = build_model(Vendor, vendor_id=account.vendor_id, company_brand=Brand.SME) subject = faker.sentence() sender_name = faker.first_name() email_username = faker.user_name() audience = create_pg_model( Audience, target=AudienceTarget.EMAIL, vendor_id=account.vendor_id, subaccount_id=account.subaccount_id, fan_count=100, ) email_domain = create_model( EmailDomain, brand=vendor.brand, vendor_id=None, subaccount_id=None ) campaign = create_model( EmailCampaign, email_domain=email_domain, audience_id=audience.id, status=EmailCampaignStatus.DRAFT, vendor_id=account.vendor_id, subaccount_id=account.subaccount_id, global_participant_id=audience.global_participant_id, subject=subject, sender_name=sender_name, email_username=email_username, custom_list_id=None, ) ows_account_client_mock.get_vendor.return_value = vendor response = client.put( f"/campaigns/{campaign.id}", json={ "name": campaign.name, "vendorId": account.vendor_id, "subaccountId": account.subaccount_id, "fanDataListId": { "id": campaign.global_participant_id, "type": "ARTIST", }, "senderName": sender_name, "emailDomainId": email_domain.id, "emailUsername": email_username, "subject": subject, "previewText": campaign.preview_text, "audienceId": audience.id, }, ) assert response.status_code == 200 assert response.json() == { "id": campaign.id, "name": campaign.name, "vendorId": campaign.vendor_id, "subaccountId": campaign.subaccount_id, "fanDataListId": { "id": campaign.global_participant_id, "type": "ARTIST", }, "audienceId": campaign.audience_id, "status": campaign.status, "emailDomain": { "id": email_domain.id, "domain": email_domain.domain, "brand": email_domain.brand, }, "emailUsername": campaign.email_username, "previewText": campaign.preview_text, "senderName": campaign.sender_name, "sendAt": IsISODatetimeOrNone(campaign.send_at), "sendAtTimezone": campaign.send_at_timezone, "recipientsCount": campaign.recipients_count, "subject": campaign.subject, "cancelReason": campaign.cancel_reason, "cancelledAt": IsISODatetimeOrNone(campaign.cancelled_at), "createdAt": IsISODatetime(campaign.created_at), "createdBy": campaign.created_by, "updatedAt": IsISODatetime(campaign.updated_at), "hasUnappliedChanges": campaign.has_unapplied_changes, "updatedBy": campaign.updated_by, "audience": { "id": audience.id, "name": audience.name, "fanCount": audience.fan_count, "isArchived": audience.is_archived, "target": audience.target, }, "analytics": None, } @pytest.mark.db def test_send_test_email_email_campaign_validate_emails( client: TestClient, create_model: CreateModel, preference_center_encrypter_mock: mock.MagicMock, ) -> None: preference_center_encrypter_mock.encrypt.return_value = b"token" campaign = create_model(EmailCampaign) response = client.post( f"/campaigns/{campaign.id}/send-test-email", json={ "emails": ["@gmail.com"], "content": base64.b64encode(b"test").decode(), }, ) assert response.status_code == 422 assert response.json() == { "code": "invalid_input", "message": "Invalid input", "fieldErrors": { "emails.0": { "code": "value_error", "message": IsStr(), } }, } @pytest.mark.db def test_schedule_email_campaign( client: TestClient, build_model: BuildModel, create_model: CreateModel, create_pg_model: CreatePgModel, faker: faker.Faker, stripo_client_mock: mock.MagicMock, ) -> None: vendor = build_model(Vendor, company_brand=Brand.SME) subject = faker.sentence() sender_name = faker.first_name() email_username = faker.user_name() audience = create_pg_model( Audience, target=AudienceTarget.EMAIL, vendor_id=vendor.vendor_id, fan_count=100, ) email_domain = create_model(EmailDomain, brand=vendor.brand) campaign = create_model( EmailCampaign, email_domain=email_domain, audience_id=audience.id, status=EmailCampaignStatus.DRAFT, vendor_id=audience.vendor_id, subaccount_id=audience.subaccount_id, global_participant_id=audience.global_participant_id, subject=subject, sender_name=sender_name, email_username=email_username, ) stripo_client_mock.compress.return_value = "compressed" response = client.post( f"/campaigns/{campaign.id}/schedule", json={ "sendAt": faker.future_datetime(tzinfo=timezone.UTC).isoformat(), "timezone": "UTC", }, ) assert response.status_code == 204 @pytest.mark.db def test_unschedule_email_campaign( client: TestClient, create_model: CreateModel ) -> None: campaign = create_model( EmailCampaign, status=EmailCampaignStatus.SCHEDULED, send_at=timezone.now() + timedelta(days=1), ) response = client.post(f"/campaigns/{campaign.id}/unschedule") assert response.status_code == 204 @pytest.mark.db def test_send_email_campaign( client: TestClient, build_model: BuildModel, create_model: CreateModel, create_pg_model: CreatePgModel, faker: faker.Faker, stripo_client_mock: mock.MagicMock, ) -> None: vendor = build_model(Vendor, company_brand=Brand.SME) subject = faker.sentence() sender_name = faker.first_name() email_username = faker.user_name() audience = create_pg_model( Audience, target=AudienceTarget.EMAIL, vendor_id=vendor.vendor_id, fan_count=100, ) email_domain = create_model(EmailDomain, brand=vendor.brand) campaign = create_model( EmailCampaign, email_domain=email_domain, audience_id=audience.id, status=EmailCampaignStatus.DRAFT, vendor_id=audience.vendor_id, subaccount_id=audience.subaccount_id, global_participant_id=audience.global_participant_id, subject=subject, sender_name=sender_name, email_username=email_username, ) stripo_client_mock.compress.return_value = "compressed" response = client.post(f"/campaigns/{campaign.id}/send") assert response.status_code == 204 @pytest.mark.db def test_duplicate_email_campaign( client: TestClient, identity_id: str, create_model: CreateModel, create_pg_model: CreatePgModel, ) -> None: email_domain = create_model(EmailDomain) audience = create_pg_model(Audience, target=AudienceTarget.EMAIL) campaign = create_model( EmailCampaign, email_domain=email_domain, audience_id=audience.id, custom_list_id=None, ) new_name = f"{campaign.name} (Copy)" response = client.post( f"/campaigns/{campaign.id}/duplicate", json={"name": new_name}, ) assert response.status_code == 201 assert response.json() == { "id": IsStr(), "name": new_name, "vendorId": campaign.vendor_id, "subaccountId": campaign.subaccount_id, "fanDataListId": { "id": campaign.global_participant_id, "type": "ARTIST", }, "audienceId": campaign.audience_id, "audience": { "id": audience.id, "name": audience.name, "fanCount": audience.fan_count, "isArchived": audience.is_archived, "target": audience.target, }, "status": "DRAFT", "emailDomain": { "id": email_domain.id, "domain": email_domain.domain, "brand": email_domain.brand, }, "emailUsername": campaign.email_username, "previewText": campaign.preview_text, "senderName": campaign.sender_name, "sendAt": None, "sendAtTimezone": None, "recipientsCount": audience.fan_count, "subject": campaign.subject, "cancelReason": None, "cancelledAt": None, "createdAt": IsStr(), "createdBy": identity_id, "updatedAt": IsStr(), "hasUnappliedChanges": False, "updatedBy": identity_id, "analytics": None, } @pytest.fixture def _real_google_fonts_service(container: Container, db) -> Iterator[None]: from cachelib import BaseCache from email_campaigns.fonts.services.google_fonts import GoogleFontsService cache_mock = mock.MagicMock(spec=BaseCache) cache_mock.get.return_value = None real_service = GoogleFontsService( google_fonts_client=mock.MagicMock(), cache=cache_mock, db=db, ) with container.override(GoogleFontsService, real_service): yield @pytest.mark.db def test_get_email_campaign_preview( client: TestClient, create_model: CreateModel, _real_google_fonts_service: None ) -> None: campaign = create_model(EmailCampaign, custom_fonts=None) response = client.get( f"/campaigns/{campaign.id}/preview", ) assert response.status_code == 200 assert response.json() == { "id": campaign.id, "name": campaign.name, "subject": campaign.subject, "previewText": campaign.preview_text, "senderName": campaign.sender_name, "html": campaign.preview.html, "css": campaign.preview.css, "customFonts": None, "favoriteFonts": [], } @pytest.mark.db def test_get_email_campaign_preview_includes_custom_fonts( client: TestClient, create_model: CreateModel ) -> None: from email_campaigns.api.schemas import CustomFont font = CustomFont( cssFontFamily="'Roboto', sans-serif", name="Roboto", url="https://fonts.example.com/roboto", ) campaign = create_model(EmailCampaign, custom_fonts=[font]) response = client.get(f"/campaigns/{campaign.id}/preview") assert response.status_code == 200 assert response.json()["customFonts"] == [ { "cssFontFamily": "'Roboto', sans-serif", "name": "Roboto", "url": "https://fonts.example.com/roboto", } ] @pytest.mark.db def test_get_email_campaign_preview_favorite_fonts_ordered_by_usage( client: TestClient, create_model: CreateModel, _real_google_fonts_service: None ) -> None: from email_campaigns.api.schemas import CustomFont from email_campaigns.automated.models import AutomatedEmail roboto = CustomFont( cssFontFamily="'Roboto', sans-serif", name="Roboto", url="https://fonts.example.com/roboto", ) lato = CustomFont( cssFontFamily="'Lato', sans-serif", name="Lato", url="https://fonts.example.com/lato", ) base = create_model(EmailCampaign, custom_fonts=None) # Roboto used in 2 SENT campaigns + 1 automated email = 3 uses # Lato used in 1 SENT campaign = 1 use create_model( EmailCampaign, global_participant_id=base.global_participant_id, vendor_id=base.vendor_id, subaccount_id=base.subaccount_id, status=EmailCampaignStatus.SENT, custom_fonts=[roboto, lato], ) create_model( EmailCampaign, global_participant_id=base.global_participant_id, vendor_id=base.vendor_id, subaccount_id=base.subaccount_id, status=EmailCampaignStatus.SENT, custom_fonts=[roboto], ) create_model( AutomatedEmail, global_participant_id=base.global_participant_id, vendor_id=base.vendor_id, subaccount_id=base.subaccount_id, custom_fonts=[roboto], ) response = client.get(f"/campaigns/{base.id}/preview") assert response.status_code == 200 favorite_fonts = response.json()["favoriteFonts"] assert len(favorite_fonts) == 2 assert favorite_fonts[0]["name"] == "Roboto" assert favorite_fonts[1]["name"] == "Lato" @pytest.mark.db def test_get_email_campaign_preview_favorite_fonts_excludes_draft_campaigns( client: TestClient, create_model: CreateModel, _real_google_fonts_service: None ) -> None: from email_campaigns.api.schemas import CustomFont lato = CustomFont( cssFontFamily="'Lato', sans-serif", name="Lato", url="https://fonts.example.com/lato", ) base = create_model(EmailCampaign, custom_fonts=None) create_model( EmailCampaign, global_participant_id=base.global_participant_id, vendor_id=base.vendor_id, subaccount_id=base.subaccount_id, status=EmailCampaignStatus.DRAFT, custom_fonts=[lato], ) response = client.get(f"/campaigns/{base.id}/preview") assert response.status_code == 200 assert response.json()["favoriteFonts"] == [] @pytest.mark.db def test_get_email_campaign_preview_favorite_fonts_count_not_inflated_by_non_sent( client: TestClient, create_model: CreateModel, _real_google_fonts_service: None ) -> None: from email_campaigns.api.schemas import CustomFont roboto = CustomFont( cssFontFamily="'Roboto', sans-serif", name="Roboto", url="https://fonts.example.com/roboto", ) lato = CustomFont( cssFontFamily="'Lato', sans-serif", name="Lato", url="https://fonts.example.com/lato", ) base = create_model(EmailCampaign, custom_fonts=None) # Roboto appears in 1 SENT campaign — its count must be 1, not 2 create_model( EmailCampaign, global_participant_id=base.global_participant_id, vendor_id=base.vendor_id, subaccount_id=base.subaccount_id, status=EmailCampaignStatus.SENT, custom_fonts=[roboto], ) # Roboto also in a DRAFT campaign — must not inflate the count # Lato only in a DRAFT campaign — must not appear at all create_model( EmailCampaign, global_participant_id=base.global_participant_id, vendor_id=base.vendor_id, subaccount_id=base.subaccount_id, status=EmailCampaignStatus.DRAFT, custom_fonts=[roboto, lato], ) response = client.get(f"/campaigns/{base.id}/preview") assert response.status_code == 200 favorite_fonts = response.json()["favoriteFonts"] assert len(favorite_fonts) == 1 assert favorite_fonts[0]["name"] == "Roboto" @pytest.mark.db def test_get_email_campaign_preview_with_substitutions( client: TestClient, create_model: CreateModel, sendgrid_client_mock: mock.MagicMock, ) -> None: sendgrid_client_mock.get_user_privacy_footer_block.return_value = "