import pytest from infrasynth.notifications.channels.base import Attachment from infrasynth.notifications.models import ( ChannelConfig, NotificationDispatch, NotificationTemplate, ) from infrasynth.notifications.resolvers import VariableResolverRegistry from infrasynth.notifications.services import NotificationService pytestmark = pytest.mark.django_db @pytest.fixture def sync_dispatch(settings): settings.INFRASYNTH_NOTIFICATIONS = { **settings.INFRASYNTH_NOTIFICATIONS, "DISPATCH_BACKEND": "sync", } @pytest.fixture def email_channel_config(): return ChannelConfig.objects.create( slug="email", channel_type="email", display_name="Email", config={ "host": "smtp.example.com", "backend": "django.core.mail.backends.locmem.EmailBackend", }, ) @pytest.fixture def sms_channel_config(): return ChannelConfig.objects.create( slug="sms", channel_type="sms", display_name="SMS", config={}, ) @pytest.fixture def no_retries(settings): settings.INFRASYNTH_NOTIFICATIONS = { **settings.INFRASYNTH_NOTIFICATIONS, "MAX_RETRIES": 0, } class TestSend: def test_creates_dispatch_and_sends(self, user, sync_dispatch, email_channel_config): dispatch = NotificationService().send( recipient="a@b.com", subject="Welcome", body="Hello!", channel="email", ) dispatch.refresh_from_db() assert dispatch.status == NotificationDispatch.Status.SENT assert dispatch.recipient == "a@b.com" assert dispatch.subject == "Welcome" assert dispatch.body == "Hello!" assert dispatch.completed_at is not None def test_renders_template(self, sync_dispatch, email_channel_config): template = NotificationTemplate.objects.create( slug="welcome", name="Welcome", channel="email", subject_template="Hi {{ user_name }}", body_template="Hello {{ user_name }}, welcome!", is_html=False, ) dispatch = NotificationService().send( recipient="a@b.com", subject="", body="", channel="email", template=template, context={"user_name": "John"}, ) dispatch.refresh_from_db() assert dispatch.subject == "Hi John" assert dispatch.body == "Hello John, welcome!" def test_emits_notification_sent_signal(self, sync_dispatch, email_channel_config): from infrasynth.notifications.signals import notification_sent sent = [] receiver = lambda **kw: sent.append(kw) # noqa: E731 notification_sent.connect(receiver, weak=False) dispatch = NotificationService().send( recipient="a@b.com", subject="S", body="B", channel="email", ) assert sent assert sent[0]["dispatch_id"] == dispatch.id assert sent[0]["recipient"] == "a@b.com" assert sent[0]["status"] == "sent" notification_sent.disconnect(receiver) def test_no_channel_config_schedules_retry(self, user, sync_dispatch): dispatch = NotificationService().send( recipient="a@b.com", subject="S", body="B", channel="email", ) dispatch.refresh_from_db() assert dispatch.status == NotificationDispatch.Status.RETRYING assert "SMTP host not configured" in dispatch.error_message assert dispatch.next_retry_at is not None def test_celery_backend_delivers_eagerly(self, user, email_channel_config): dispatch = NotificationService().send( recipient="a@b.com", subject="S", body="B", channel="email", ) dispatch.refresh_from_db() assert dispatch.status == NotificationDispatch.Status.SENT def test_snapshot_stringifies_non_json_values(self, sync_dispatch, email_channel_config): class _Obj: def __str__(self): return "obj-string" dispatch = NotificationService().send( recipient="a@b.com", subject="S", body="B", channel="email", context={"plain": "x", "obj": _Obj()}, ) assert dispatch.context_snapshot["plain"] == "x" assert dispatch.context_snapshot["obj"] == "obj-string" class TestSendWithFailover: def test_primary_fails_fallback_succeeds(self, user, sync_dispatch, email_channel_config, sms_channel_config): dispatch = NotificationService().send_with_failover( recipient="+573001234567", subject="S", body="B", channels=["sms", "email"], ) dispatch.refresh_from_db() assert dispatch.status == NotificationDispatch.Status.SENT assert dispatch.channel == "email" assert dispatch.attempt == 1 def test_all_channels_fail(self, user, sync_dispatch, sms_channel_config, no_retries): dispatch = NotificationService().send_with_failover( recipient="+573001234567", subject="S", body="B", channels=["sms"], ) dispatch.refresh_from_db() assert dispatch.status == NotificationDispatch.Status.FAILED assert "Twilio credentials not configured" in dispatch.error_message def test_emits_failed_signal(self, user, sync_dispatch, sms_channel_config, no_retries): from infrasynth.notifications.signals import notification_failed failed = [] receiver = lambda **kw: failed.append(kw) # noqa: E731 notification_failed.connect(receiver, weak=False) dispatch = NotificationService().send_with_failover( recipient="+573001234567", subject="S", body="B", channels=["sms"], ) assert failed assert failed[0]["dispatch_id"] == dispatch.id assert "credentials" in failed[0]["error"] notification_failed.disconnect(receiver) class TestGetTemplate: def test_returns_template_by_slug(self): NotificationTemplate.objects.create( slug="welcome", name="Welcome", channel="email", subject_template="", body_template="Hello", namespace="", ) template = NotificationService().get_template("welcome") assert template is not None assert template.slug == "welcome" def test_namespace_scoping(self): NotificationTemplate.objects.create( slug="welcome", name="Welcome", channel="email", subject_template="", body_template="Global", namespace="", ) NotificationTemplate.objects.create( slug="welcome", name="Welcome NS", channel="email", subject_template="", body_template="Namespaced", namespace="helpdesk", ) service = NotificationService() assert service.get_template("welcome").body_template == "Global" assert service.get_template("welcome", namespace="helpdesk").body_template == "Namespaced" assert service.get_template("missing") is None class TestVariableResolversInTemplates: def test_namespace_resolver_used_in_render(self, sync_dispatch, email_channel_config): def resolve_agent(recipient, context, request=None): return "Agent Smith" VariableResolverRegistry.register("agent_name", namespace="helpdesk")(resolve_agent) try: template = NotificationTemplate.objects.create( slug="assigned", name="Assigned", channel="email", subject_template="Ticket {{ ticket_id }}", body_template="Assigned to {{ agent_name }}", namespace="helpdesk", is_html=False, ) dispatch = NotificationService().send( recipient="a@b.com", subject="", body="", channel="email", template=template, context={"ticket_id": 7}, ) dispatch.refresh_from_db() assert dispatch.subject == "Ticket 7" assert dispatch.body == "Assigned to Agent Smith" finally: VariableResolverRegistry._resolvers.clear() class TestAttachmentsPassThrough: def test_send_with_attachment(self, sync_dispatch, email_channel_config): from infrasynth.notifications.services import NotificationService dispatch = NotificationService().send( recipient="a@b.com", subject="S", body="B", channel="email", ) dispatch.refresh_from_db() assert dispatch.status == NotificationDispatch.Status.SENT def test_attachment_dataclass_usable(self): attachment = Attachment("a.txt", b"x", "text/plain") assert attachment.filename == "a.txt"