from unittest import mock import pytest from fansifter_common.auth.types import Permission from email_campaigns.api.schemas import CustomFont from email_campaigns.emails.exceptions import EmailNotFoundError from email_campaigns.emails.handlers.update_email_fonts import ( UpdateEmailFontsHandler, UpdateEmailFontsRequest, ) from email_campaigns.emails.models import BaseEmail from email_campaigns.fonts.exceptions import InvalidGoogleFontError from email_campaigns.fonts.services.google_fonts import GoogleFont, GoogleFontsService class _ConcreteHandler(UpdateEmailFontsHandler[BaseEmail]): permission = Permission("email_campaign", "edit") def _font(name: str, css: str = "", url: str = "") -> CustomFont: return CustomFont(cssFontFamily=css, name=name, url=url) def _cached_font(name: str) -> GoogleFont: return GoogleFont( name=name, fallback_font="Arial", generic_font_family="sans-serif", css_font_family=f"'{name}', Arial, sans-serif", url=f"https://fonts.googleapis.com/css2?family={name}", ) def _request(font: CustomFont) -> UpdateEmailFontsRequest: return UpdateEmailFontsRequest(identity_id="user-1", email_id="email-1", font=font) @pytest.fixture def email_mock() -> mock.MagicMock: email = mock.MagicMock() email.custom_fonts = None return email @pytest.fixture def repo_mock(email_mock: mock.MagicMock) -> mock.MagicMock: repo = mock.MagicMock() repo.get.return_value = email_mock return repo @pytest.fixture def fonts_cache_mock() -> mock.MagicMock: cache = mock.MagicMock() cache.is_populated.return_value = True cache.get_font.side_effect = lambda name: _cached_font(name) return cache @pytest.fixture def fonts_service_mock() -> mock.MagicMock: return mock.MagicMock(spec=GoogleFontsService) @pytest.fixture def handler( repo_mock: mock.MagicMock, fonts_cache_mock: mock.MagicMock, fonts_service_mock: mock.MagicMock, ) -> _ConcreteHandler: return _ConcreteHandler( auth_service=mock.MagicMock(), email_repository=repo_mock, google_fonts_cache=fonts_cache_mock, google_fonts_service=fonts_service_mock, ) class TestUpdateEmailFontsHandler: def test_raises_when_email_not_found( self, handler: _ConcreteHandler, repo_mock: mock.MagicMock ) -> None: repo_mock.get.return_value = None with pytest.raises(EmailNotFoundError): handler.handle(_request(_font("Roboto"))) def test_adds_font_to_empty_list( self, handler: _ConcreteHandler, email_mock: mock.MagicMock ) -> None: email_mock.custom_fonts = None result = handler.handle(_request(_font("Roboto"))) assert len(result) == 1 assert result[0].name == "Roboto" assert result[0].css_font_family == "'Roboto', Arial, sans-serif" def test_new_font_appended_at_end( self, handler: _ConcreteHandler, email_mock: mock.MagicMock ) -> None: email_mock.custom_fonts = [_font("Roboto"), _font("Lato")] result = handler.handle(_request(_font("Inter"))) assert [f.name for f in result] == ["Roboto", "Lato", "Inter"] def test_existing_font_replaced_with_cached_version( self, handler: _ConcreteHandler, email_mock: mock.MagicMock ) -> None: email_mock.custom_fonts = [ _font("Roboto", css="old-css", url="old-url"), _font("Lato"), ] result = handler.handle(_request(_font("Roboto"))) assert result[0].css_font_family == "'Roboto', Arial, sans-serif" assert result[0].url == "https://fonts.googleapis.com/css2?family=Roboto" def test_existing_font_update_preserves_order( self, handler: _ConcreteHandler, email_mock: mock.MagicMock ) -> None: email_mock.custom_fonts = [_font("Roboto"), _font("Lato"), _font("Inter")] result = handler.handle(_request(_font("Lato"))) assert [f.name for f in result] == ["Roboto", "Lato", "Inter"] def test_saves_email_with_cached_font_data( self, handler: _ConcreteHandler, repo_mock: mock.MagicMock, email_mock: mock.MagicMock, ) -> None: email_mock.custom_fonts = None handler.handle(_request(_font("Roboto"))) saved_email = repo_mock.save.call_args.args[0] assert saved_email.custom_fonts[0].name == "Roboto" assert ( saved_email.custom_fonts[0].css_font_family == "'Roboto', Arial, sans-serif" ) def test_raises_when_font_not_in_google_fonts( self, handler: _ConcreteHandler, fonts_cache_mock: mock.MagicMock, ) -> None: fonts_cache_mock.get_font.side_effect = None fonts_cache_mock.get_font.return_value = None with pytest.raises(InvalidGoogleFontError): handler.handle(_request(_font("NotAGoogleFont"))) def test_populates_cache_when_not_populated( self, handler: _ConcreteHandler, fonts_cache_mock: mock.MagicMock, fonts_service_mock: mock.MagicMock, ) -> None: fonts_cache_mock.is_populated.return_value = False handler.handle(_request(_font("Roboto"))) fonts_service_mock.get_fonts.assert_called_once() fonts_cache_mock.populate.assert_called_once() def test_skips_api_call_when_cache_populated( self, handler: _ConcreteHandler, fonts_cache_mock: mock.MagicMock, fonts_service_mock: mock.MagicMock, ) -> None: fonts_cache_mock.is_populated.return_value = True handler.handle(_request(_font("Roboto"))) fonts_service_mock.get_fonts.assert_not_called() fonts_cache_mock.populate.assert_not_called()