feat(notifications): added notifications business logic

This commit is contained in:
ITQ
2026-02-23 10:57:03 +03:00
parent fd94994286
commit f3350ff81e
10 changed files with 926 additions and 0 deletions
@@ -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()