import datetime from unittest import mock import pytest from email_campaigns.adapters.db import DB from email_campaigns.adapters.google.client import ( GoogleFontsClient, ) from email_campaigns.api.schemas import CustomFont from email_campaigns.automated.models import AutomatedEmail from email_campaigns.config import Settings from email_campaigns.fonts.services.google_fonts import ( GoogleFont, GoogleFontsService, ) from email_campaigns.rosters.types import FanDataListId from tests.unit.types import CreateModel _SANS_ITEM = { "kind": "webfonts#webfont", "family": "Roboto", "variants": ["regular", "italic", "700", "700italic"], "subsets": ["latin"], "version": "v30", "lastModified": "2022-01-01", "category": "sans-serif", "menu": "https://fonts.gstatic.com/s/roboto/v30/menu.ttf", "files": {"regular": "https://fonts.gstatic.com/s/roboto/v30/regular.woff2"}, } _MONO_ITEM = { "kind": "webfonts#webfont", "family": "Roboto Mono", "variants": ["regular", "700"], "subsets": ["latin"], "version": "v22", "lastModified": "2022-01-01", "category": "monospace", "menu": "https://fonts.gstatic.com/s/robotomono/v22/menu.ttf", "files": {"regular": "https://fonts.gstatic.com/s/robotomono/v22/regular.woff2"}, } _VARIABLE_ITEM = { "kind": "webfonts#webfont", "family": "Inter", "variants": ["regular", "700"], "subsets": ["latin"], "version": "v13", "lastModified": "2023-01-01", "category": "sans-serif", "menu": "https://fonts.gstatic.com/s/inter/v13/menu.ttf", "files": {"regular": "https://fonts.gstatic.com/s/inter/v13/regular.woff2"}, "axes": [ {"tag": "wght", "start": 100, "end": 900}, ], } _VARIABLE_ITALIC_ITEM = { "kind": "webfonts#webfont", "family": "Roboto Flex", "variants": ["regular"], "subsets": ["latin"], "version": "v9", "lastModified": "2023-01-01", "category": "sans-serif", "menu": "https://fonts.gstatic.com/s/robotoflex/v9/menu.ttf", "files": {"regular": "https://fonts.gstatic.com/s/robotoflex/v9/regular.woff2"}, "axes": [ {"tag": "ital", "start": 0, "end": 1}, {"tag": "wght", "start": 100, "end": 900}, ], } # Variable font whose italic support comes from variants, not an ital axis _VARIABLE_ITALIC_FROM_VARIANTS_ITEM = { "kind": "webfonts#webfont", "family": "Martel", "variants": [ "200", "300", "regular", "600", "700", "800", "900", "200italic", "300italic", "italic", "600italic", "700italic", "800italic", "900italic", ], "subsets": ["latin"], "version": "v1", "lastModified": "2023-01-01", "category": "serif", "menu": "https://fonts.gstatic.com/s/martel/v1/menu.ttf", "files": {"regular": "https://fonts.gstatic.com/s/martel/v1/regular.woff2"}, "axes": [{"tag": "wght", "start": 200, "end": 900}], } # Italic-only font — no upright (normal) style available _ITALIC_ONLY_ITEM = { "kind": "webfonts#webfont", "family": "Molle", "variants": ["italic"], "subsets": ["latin", "latin-ext"], "version": "v25", "lastModified": "2025-09-05", "category": "handwriting", "menu": "https://fonts.gstatic.com/s/molle/v25/menu.ttf", "files": {"italic": "https://fonts.gstatic.com/s/molle/v25/regular.woff2"}, } _MATERIAL_ICONS_ITEM = { "kind": "webfonts#webfont", "family": "Material Icons", "variants": ["regular"], "subsets": ["latin"], "version": "v140", "lastModified": "2023-01-01", "category": "sans-serif", "menu": "https://fonts.gstatic.com/s/materialicons/v140/menu.ttf", "files": { "regular": "https://fonts.gstatic.com/s/materialicons/v140/regular.woff2" }, } # Decorative font with only a single weight — Bold/Italic unavailable _SINGLE_WEIGHT_ITEM = { "kind": "webfonts#webfont", "family": "Borel", "variants": ["regular"], "subsets": ["latin"], "version": "v1", "lastModified": "2023-01-01", "category": "display", "menu": "https://fonts.gstatic.com/s/borel/v1/menu.ttf", "files": {"regular": "https://fonts.gstatic.com/s/borel/v1/regular.woff2"}, } def _make_api_response(*items: dict) -> dict: return {"kind": "webfonts#webfontList", "items": list(items)} @pytest.fixture def service() -> GoogleFontsService: settings_mock = mock.MagicMock(spec=Settings) settings_mock.google_fonts_api_key = "test-api-key" return GoogleFontsService( google_fonts_client=GoogleFontsClient(settings_mock), cache=mock.MagicMock(), db=mock.MagicMock(), ) @pytest.fixture def mock_http(): with mock.patch( "email_campaigns.adapters.google.client.httpx.Client" ) as mock_client_cls: http_mock = mock.MagicMock() http_mock.raise_for_status.return_value = None mock_client_cls.return_value.__enter__.return_value.get.return_value = http_mock yield mock_client_cls, http_mock class TestGoogleFontsService: def test_requests_sorted_by_popularity( self, service: GoogleFontsService, mock_http: tuple ) -> None: mock_client_cls, http_mock = mock_http http_mock.json.return_value = _make_api_response(_SANS_ITEM) service.get_fonts() mock_client_cls.return_value.__enter__.return_value.get.assert_called_once_with( "https://www.googleapis.com/webfonts/v1/webfonts", params={"key": "test-api-key", "sort": "popularity"}, ) def test_returns_mapped_google_font_objects( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response(_SANS_ITEM) result = service.get_fonts() assert result == [ GoogleFont( name="Roboto", fallback_font="Arial", generic_font_family="sans-serif", css_font_family="'Roboto', Arial, sans-serif", url="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400;1,700&display=swap", ) ] def test_static_font_url_lists_individual_weights( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response(_SANS_ITEM) result = service.get_fonts() assert "ital,wght@0,400;0,700;1,400;1,700" in result[0].url def test_static_url_normal_only_weights( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response(_MONO_ITEM) result = service.get_fonts() assert result[0].url == ( "https://fonts.googleapis.com/css2" "?family=Roboto%20Mono:wght@400;700&display=swap" ) def test_variable_url_uses_weight_range( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response(_VARIABLE_ITEM) result = service.get_fonts() assert result[0].url == ( "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" ) def test_variable_url_includes_ital_axis_when_present( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response(_VARIABLE_ITALIC_ITEM) result = service.get_fonts() assert result[0].url == ( "https://fonts.googleapis.com/css2" "?family=Roboto%20Flex:ital,wght@0,100..900;1,100..900&display=swap" ) def test_italic_detected_from_variants_when_no_ital_axis( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response( _VARIABLE_ITALIC_FROM_VARIANTS_ITEM ) result = service.get_fonts() assert ( "ital,wght@0,200..900;1,200;1,300;1,400;1,600;1,700;1,800;1,900" in result[0].url ) def test_single_weight_font_url_has_no_range( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response(_SINGLE_WEIGHT_ITEM) result = service.get_fonts() assert result[0].url == ( "https://fonts.googleapis.com/css2?family=Borel:wght@400&display=swap" ) def test_italic_only_font_uses_ital_wght_spec( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response(_ITALIC_ONLY_ITEM) result = service.get_fonts() assert result[0].url == ( "https://fonts.googleapis.com/css2?family=Molle:ital,wght@1,400&display=swap" ) def test_icon_fonts_are_excluded( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response( _SANS_ITEM, _MATERIAL_ICONS_ITEM ) result = service.get_fonts() assert len(result) == 1 assert result[0].name == "Roboto" def test_returns_multiple_fonts( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response(_SANS_ITEM, _MONO_ITEM) result = service.get_fonts() assert len(result) == 2 assert result[0].name == "Roboto" assert result[1].name == "Roboto Mono" def test_raise_for_status_is_called( self, service: GoogleFontsService, mock_http: tuple ) -> None: _, http_mock = mock_http http_mock.json.return_value = _make_api_response() service.get_fonts() http_mock.raise_for_status.assert_called_once() @pytest.mark.db def test_get_favorite_fonts_for_email_ignoring_not_used_automated_emails( self, db: DB, create_model: CreateModel, ) -> None: participant_id = "test-participant-id" vendor_id = 1 roboto = CustomFont( name="Roboto", cssFontFamily="'Roboto', Arial, sans-serif", url="https://fonts.googleapis.com/css2?family=Roboto", ) lato = CustomFont( name="Lato", cssFontFamily="'Lato', Arial, sans-serif", url="https://fonts.googleapis.com/css2?family=Lato", ) create_model( AutomatedEmail, vendor_id=vendor_id, global_participant_id=participant_id, custom_list_id=None, custom_fonts=[roboto], last_sent_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), ) create_model( AutomatedEmail, vendor_id=vendor_id, global_participant_id=participant_id, custom_list_id=None, custom_fonts=[lato], last_sent_at=None, ) db.session.flush() cache = mock.MagicMock() cache.get.return_value = None service = GoogleFontsService( google_fonts_client=mock.MagicMock(), cache=cache, db=db, ) email = mock.MagicMock() email.id = "test-email-id" email.vendor_id = vendor_id email.fandata_list_id = FanDataListId.from_artist_id(participant_id) result = service.get_favorite_fonts_for_email(email) result_names = {f.name for f in result} assert "Roboto" in result_names assert "Lato" not in result_names def test_get_favorite_fonts_for_email_cache_key_includes_participant_id( self, ) -> None: cache = mock.MagicMock() cache.get.return_value = None db = mock.MagicMock() db.session.execute.return_value.all.return_value = [] service = GoogleFontsService( google_fonts_client=mock.MagicMock(), cache=cache, db=db, ) email = mock.MagicMock() email.id = "email-1" email.fandata_list_id = FanDataListId.from_artist_id("participant-abc") service.get_favorite_fonts_for_email(email) cache_key = cache.get.call_args[0][0] assert "email-1" in cache_key assert "participant-abc" in cache_key