feat(notifications): added notifications business logic
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
from typing import Any, override
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.experiments.tests.helpers import make_experiment
|
||||
from apps.notifications.models import (
|
||||
ChannelType,
|
||||
NotificationChannel,
|
||||
NotificationEventType,
|
||||
NotificationLog,
|
||||
NotificationRule,
|
||||
NotificationStatus,
|
||||
)
|
||||
from apps.notifications.services import (
|
||||
NotificationPayload,
|
||||
channel_create,
|
||||
channel_delete,
|
||||
channel_get,
|
||||
channel_list,
|
||||
channel_update,
|
||||
flush_pending_notifications,
|
||||
notification_enqueue,
|
||||
rule_create,
|
||||
rule_delete,
|
||||
rule_list,
|
||||
rule_update,
|
||||
)
|
||||
|
||||
|
||||
class ChannelCRUDTest(TestCase):
|
||||
def test_create_channel(self) -> None:
|
||||
ch = channel_create(
|
||||
channel_type=ChannelType.TELEGRAM,
|
||||
name="Team Chat",
|
||||
config={"bot_token": "123", "chat_id": "-100"},
|
||||
)
|
||||
self.assertEqual(ch.channel_type, ChannelType.TELEGRAM)
|
||||
self.assertEqual(ch.name, "Team Chat")
|
||||
self.assertEqual(ch.config["bot_token"], "123")
|
||||
self.assertTrue(ch.is_active)
|
||||
|
||||
def test_update_channel(self) -> None:
|
||||
ch = channel_create(
|
||||
channel_type=ChannelType.SMTP,
|
||||
name="Old Name",
|
||||
)
|
||||
ch = channel_update(channel=ch, name="New Name")
|
||||
self.assertEqual(ch.name, "New Name")
|
||||
|
||||
def test_update_channel_disallowed_field(self) -> None:
|
||||
ch = channel_create(
|
||||
channel_type=ChannelType.SMTP,
|
||||
name="X",
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
channel_update(channel=ch, channel_type="telegram")
|
||||
|
||||
def test_delete_channel(self) -> None:
|
||||
ch = channel_create(
|
||||
channel_type=ChannelType.TELEGRAM,
|
||||
name="Delete Me",
|
||||
)
|
||||
pk = ch.pk
|
||||
channel_delete(channel=ch)
|
||||
self.assertIsNone(channel_get(pk))
|
||||
|
||||
def test_list_channels(self) -> None:
|
||||
channel_create(channel_type=ChannelType.TELEGRAM, name="A")
|
||||
channel_create(channel_type=ChannelType.SMTP, name="B")
|
||||
self.assertEqual(channel_list().count(), 2)
|
||||
|
||||
|
||||
class RuleCRUDTest(TestCase):
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
self.channel = channel_create(
|
||||
channel_type=ChannelType.TELEGRAM,
|
||||
name="Test Channel",
|
||||
config={"bot_token": "tok", "chat_id": "123"},
|
||||
)
|
||||
|
||||
def test_create_rule_global(self) -> None:
|
||||
r = rule_create(
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
channel=self.channel,
|
||||
)
|
||||
self.assertEqual(r.event_type, NotificationEventType.GUARDRAIL_TRIGGERED)
|
||||
self.assertIsNone(r.experiment)
|
||||
self.assertTrue(r.is_active)
|
||||
|
||||
def test_create_rule_for_experiment(self) -> None:
|
||||
exp = make_experiment(suffix="_rule")
|
||||
r = rule_create(
|
||||
event_type=NotificationEventType.EXPERIMENT_STARTED,
|
||||
channel=self.channel,
|
||||
experiment=exp,
|
||||
)
|
||||
self.assertEqual(r.experiment_id, exp.pk)
|
||||
|
||||
def test_update_rule(self) -> None:
|
||||
r = rule_create(
|
||||
event_type=NotificationEventType.EXPERIMENT_STARTED,
|
||||
channel=self.channel,
|
||||
)
|
||||
r = rule_update(rule=r, is_active=False)
|
||||
self.assertFalse(r.is_active)
|
||||
|
||||
def test_delete_rule(self) -> None:
|
||||
r = rule_create(
|
||||
event_type=NotificationEventType.EXPERIMENT_STARTED,
|
||||
channel=self.channel,
|
||||
)
|
||||
rule_delete(rule=r)
|
||||
self.assertEqual(rule_list().count(), 0)
|
||||
|
||||
|
||||
class NotificationEnqueueTest(TestCase):
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
self.channel = channel_create(
|
||||
channel_type=ChannelType.TELEGRAM,
|
||||
name="Alerts",
|
||||
config={"bot_token": "tok", "chat_id": "-100"},
|
||||
)
|
||||
self.experiment = make_experiment(suffix="_enq")
|
||||
|
||||
def test_enqueue_creates_pending_log(self) -> None:
|
||||
rule_create(
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
channel=self.channel,
|
||||
)
|
||||
logs = notification_enqueue(
|
||||
NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
NotificationPayload(
|
||||
title="Alert",
|
||||
body="Error rate exceeded",
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
experiment_id=str(self.experiment.pk),
|
||||
experiment_name=self.experiment.name,
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(logs), 1)
|
||||
self.assertEqual(logs[0].status, NotificationStatus.PENDING)
|
||||
|
||||
def test_enqueue_deduplicates(self) -> None:
|
||||
rule_create(
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
channel=self.channel,
|
||||
)
|
||||
payload = NotificationPayload(
|
||||
title="Alert",
|
||||
body="Error rate exceeded",
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
experiment_id=str(self.experiment.pk),
|
||||
experiment_name=self.experiment.name,
|
||||
)
|
||||
logs_1 = notification_enqueue(
|
||||
NotificationEventType.GUARDRAIL_TRIGGERED, payload
|
||||
)
|
||||
logs_2 = notification_enqueue(
|
||||
NotificationEventType.GUARDRAIL_TRIGGERED, payload
|
||||
)
|
||||
self.assertEqual(len(logs_1), 1)
|
||||
self.assertEqual(len(logs_2), 0)
|
||||
|
||||
def test_enqueue_no_matching_rules(self) -> None:
|
||||
logs = notification_enqueue(
|
||||
NotificationEventType.EXPERIMENT_STARTED,
|
||||
NotificationPayload(
|
||||
title="Started",
|
||||
body="Exp started",
|
||||
event_type=NotificationEventType.EXPERIMENT_STARTED,
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(logs), 0)
|
||||
|
||||
def test_enqueue_inactive_channel_skipped(self) -> None:
|
||||
self.channel.is_active = False
|
||||
self.channel.save(update_fields=["is_active"])
|
||||
rule_create(
|
||||
event_type=NotificationEventType.EXPERIMENT_STARTED,
|
||||
channel=self.channel,
|
||||
)
|
||||
logs = notification_enqueue(
|
||||
NotificationEventType.EXPERIMENT_STARTED,
|
||||
NotificationPayload(
|
||||
title="Started",
|
||||
body="Exp started",
|
||||
event_type=NotificationEventType.EXPERIMENT_STARTED,
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(logs), 0)
|
||||
|
||||
def test_enqueue_experiment_scoped_rule(self) -> None:
|
||||
rule_create(
|
||||
event_type=NotificationEventType.EXPERIMENT_STARTED,
|
||||
channel=self.channel,
|
||||
experiment=self.experiment,
|
||||
)
|
||||
other_exp = make_experiment(suffix="_other")
|
||||
logs = notification_enqueue(
|
||||
NotificationEventType.EXPERIMENT_STARTED,
|
||||
NotificationPayload(
|
||||
title="Started",
|
||||
body="Exp started",
|
||||
event_type=NotificationEventType.EXPERIMENT_STARTED,
|
||||
experiment_id=str(other_exp.pk),
|
||||
experiment_name=other_exp.name,
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(logs), 0)
|
||||
|
||||
|
||||
class FlushNotificationsTest(TestCase):
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
self.channel = channel_create(
|
||||
channel_type=ChannelType.TELEGRAM,
|
||||
name="Flush Test",
|
||||
config={"bot_token": "tok", "chat_id": "-100"},
|
||||
)
|
||||
|
||||
@patch("apps.notifications.services._send_telegram")
|
||||
def test_flush_sends_pending(self, mock_send: Any) -> None:
|
||||
rule = rule_create(
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
channel=self.channel,
|
||||
)
|
||||
NotificationLog.objects.create(
|
||||
rule=rule,
|
||||
channel=self.channel,
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
event_key="test:key:1",
|
||||
payload={"title": "Alert", "body": "Test"},
|
||||
status=NotificationStatus.PENDING,
|
||||
)
|
||||
results = flush_pending_notifications()
|
||||
self.assertEqual(results["sent"], 1)
|
||||
self.assertEqual(results["failed"], 0)
|
||||
mock_send.assert_called_once()
|
||||
|
||||
@patch(
|
||||
"apps.notifications.services._send_telegram",
|
||||
side_effect=Exception("Network error"),
|
||||
)
|
||||
def test_flush_marks_failed_on_error(self, mock_send: Any) -> None:
|
||||
rule = rule_create(
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
channel=self.channel,
|
||||
)
|
||||
log = NotificationLog.objects.create(
|
||||
rule=rule,
|
||||
channel=self.channel,
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
event_key="test:key:2",
|
||||
payload={"title": "Alert", "body": "Test"},
|
||||
status=NotificationStatus.PENDING,
|
||||
)
|
||||
results = flush_pending_notifications()
|
||||
self.assertEqual(results["sent"], 0)
|
||||
self.assertEqual(results["failed"], 1)
|
||||
log.refresh_from_db()
|
||||
self.assertEqual(log.status, NotificationStatus.FAILED)
|
||||
self.assertIn("Network error", log.error)
|
||||
|
||||
def test_flush_skips_inactive_channel(self) -> None:
|
||||
self.channel.is_active = False
|
||||
self.channel.save(update_fields=["is_active"])
|
||||
rule = rule_create(
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
channel=self.channel,
|
||||
)
|
||||
NotificationLog.objects.create(
|
||||
rule=rule,
|
||||
channel=self.channel,
|
||||
event_type=NotificationEventType.GUARDRAIL_TRIGGERED,
|
||||
event_key="test:key:3",
|
||||
payload={"title": "Alert", "body": "Test"},
|
||||
status=NotificationStatus.PENDING,
|
||||
)
|
||||
results = flush_pending_notifications()
|
||||
self.assertEqual(results["failed"], 1)
|
||||
|
||||
@patch("apps.notifications.services._send_smtp")
|
||||
def test_flush_smtp_channel(self, mock_send: Any) -> None:
|
||||
smtp_ch = channel_create(
|
||||
channel_type=ChannelType.SMTP,
|
||||
name="Email",
|
||||
config={"recipient": "team@lotty.local"},
|
||||
)
|
||||
rule = rule_create(
|
||||
event_type=NotificationEventType.EXPERIMENT_COMPLETED,
|
||||
channel=smtp_ch,
|
||||
)
|
||||
NotificationLog.objects.create(
|
||||
rule=rule,
|
||||
channel=smtp_ch,
|
||||
event_type=NotificationEventType.EXPERIMENT_COMPLETED,
|
||||
event_key="test:key:4",
|
||||
payload={"title": "Done", "body": "Exp completed"},
|
||||
status=NotificationStatus.PENDING,
|
||||
)
|
||||
results = flush_pending_notifications()
|
||||
self.assertEqual(results["sent"], 1)
|
||||
mock_send.assert_called_once()
|
||||
Reference in New Issue
Block a user