feat(loyalty): Phase 2A — transactional email notifications
Some checks failed
Some checks failed
Add async email notifications for 5 loyalty lifecycle events, using the existing messaging module infrastructure (EmailService, EmailLog, store template overrides). - New seed script: scripts/seed/seed_email_templates_loyalty.py Seeds 5 templates × 4 locales (en/fr/de/lb) = 20 rows. Idempotent. Renamed existing script to seed_email_templates_core.py. - Celery task: loyalty.send_notification_email — async dispatch with 3 retries and 60s backoff. Opens own DB session. - Notification service: LoyaltyNotificationService with 5 methods that resolve customer/card/program into template variables and enqueue via Celery (never blocks request handlers). - Enrollment: sends loyalty_enrollment + loyalty_welcome_bonus (if bonus > 0) after card creation commit. - Stamps: sends loyalty_reward_ready when stamp target reached. - Expiration task: sends loyalty_points_expiring 14 days before expiry (tracked via new last_expiration_warning_at column to prevent dupes), and loyalty_points_expired after points are zeroed. - Migration loyalty_005: adds last_expiration_warning_at to cards. - 8 new unit tests for notification service dispatch. - Fix: rate limiter autouse fixture in integration tests to prevent state bleed between tests. Templates: loyalty_enrollment, loyalty_welcome_bonus, loyalty_points_expiring, loyalty_points_expired, loyalty_reward_ready. All support store-level overrides via the existing email template UI. Birthday + re-engagement emails deferred to future marketing module (cross-platform: OMS, loyalty, hosting). 342 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
192
app/modules/loyalty/tests/unit/test_notification_service.py
Normal file
192
app/modules/loyalty/tests/unit/test_notification_service.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Unit tests for LoyaltyNotificationService."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.services.notification_service import (
|
||||
LoyaltyNotificationService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notification_service():
|
||||
return LoyaltyNotificationService()
|
||||
|
||||
|
||||
def _make_card_stub(
|
||||
card_id=1,
|
||||
customer_id=10,
|
||||
merchant_id=100,
|
||||
enrolled_at_store_id=200,
|
||||
card_number="1234-5678-9012",
|
||||
):
|
||||
"""Lightweight card stub for notification tests."""
|
||||
card = MagicMock()
|
||||
card.id = card_id
|
||||
card.customer_id = customer_id
|
||||
card.merchant_id = merchant_id
|
||||
card.enrolled_at_store_id = enrolled_at_store_id
|
||||
card.card_number = card_number
|
||||
card.program = MagicMock()
|
||||
card.program.display_name = "Test Rewards"
|
||||
return card
|
||||
|
||||
|
||||
def _make_customer_stub(email="test@example.com", full_name="Test User"):
|
||||
customer = MagicMock()
|
||||
customer.id = 10
|
||||
customer.email = email
|
||||
customer.full_name = full_name
|
||||
return customer
|
||||
|
||||
|
||||
def _make_store_stub(name="Test Store"):
|
||||
store = MagicMock()
|
||||
store.id = 200
|
||||
store.name = name
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestNotificationServiceDispatch:
|
||||
"""Verify that each notification method enqueues the correct template."""
|
||||
|
||||
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||
def test_send_enrollment_confirmation(
|
||||
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||
):
|
||||
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||
db = MagicMock()
|
||||
card = _make_card_stub()
|
||||
|
||||
notification_service.send_enrollment_confirmation(db, card)
|
||||
|
||||
mock_task.delay.assert_called_once()
|
||||
call_kwargs = mock_task.delay.call_args[1]
|
||||
assert call_kwargs["template_code"] == "loyalty_enrollment"
|
||||
assert call_kwargs["to_email"] == "test@example.com"
|
||||
assert "card_number" in call_kwargs["variables"]
|
||||
|
||||
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||
def test_send_welcome_bonus(
|
||||
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||
):
|
||||
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||
db = MagicMock()
|
||||
card = _make_card_stub()
|
||||
|
||||
notification_service.send_welcome_bonus(db, card, 50)
|
||||
|
||||
mock_task.delay.assert_called_once()
|
||||
call_kwargs = mock_task.delay.call_args[1]
|
||||
assert call_kwargs["template_code"] == "loyalty_welcome_bonus"
|
||||
assert call_kwargs["variables"]["points"] == "50"
|
||||
|
||||
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||
def test_send_welcome_bonus_skipped_for_zero_points(
|
||||
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||
):
|
||||
db = MagicMock()
|
||||
card = _make_card_stub()
|
||||
|
||||
notification_service.send_welcome_bonus(db, card, 0)
|
||||
|
||||
mock_task.delay.assert_not_called()
|
||||
|
||||
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||
def test_send_points_expiration_warning(
|
||||
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||
):
|
||||
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||
db = MagicMock()
|
||||
card = _make_card_stub()
|
||||
|
||||
notification_service.send_points_expiration_warning(
|
||||
db, card, expiring_points=200, days_remaining=14, expiration_date="2026-05-01"
|
||||
)
|
||||
|
||||
mock_task.delay.assert_called_once()
|
||||
call_kwargs = mock_task.delay.call_args[1]
|
||||
assert call_kwargs["template_code"] == "loyalty_points_expiring"
|
||||
assert call_kwargs["variables"]["points"] == "200"
|
||||
assert call_kwargs["variables"]["days_remaining"] == "14"
|
||||
|
||||
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||
def test_send_points_expired(
|
||||
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||
):
|
||||
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||
db = MagicMock()
|
||||
card = _make_card_stub()
|
||||
|
||||
notification_service.send_points_expired(db, card, 150)
|
||||
|
||||
mock_task.delay.assert_called_once()
|
||||
call_kwargs = mock_task.delay.call_args[1]
|
||||
assert call_kwargs["template_code"] == "loyalty_points_expired"
|
||||
assert call_kwargs["variables"]["expired_points"] == "150"
|
||||
|
||||
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||
def test_send_reward_available(
|
||||
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||
):
|
||||
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
|
||||
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
|
||||
db = MagicMock()
|
||||
card = _make_card_stub()
|
||||
|
||||
notification_service.send_reward_available(db, card, "Free Coffee")
|
||||
|
||||
mock_task.delay.assert_called_once()
|
||||
call_kwargs = mock_task.delay.call_args[1]
|
||||
assert call_kwargs["template_code"] == "loyalty_reward_ready"
|
||||
assert call_kwargs["variables"]["reward_name"] == "Free Coffee"
|
||||
|
||||
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||
def test_no_email_skips_dispatch(
|
||||
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||
):
|
||||
"""Customer without email does not trigger a send."""
|
||||
no_email_customer = _make_customer_stub(email=None)
|
||||
mock_customer_svc.get_customer_by_id.return_value = no_email_customer
|
||||
db = MagicMock()
|
||||
card = _make_card_stub()
|
||||
|
||||
notification_service.send_enrollment_confirmation(db, card)
|
||||
|
||||
mock_task.delay.assert_not_called()
|
||||
|
||||
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
|
||||
@patch("app.modules.tenancy.services.store_service.store_service")
|
||||
@patch("app.modules.customers.services.customer_service.customer_service")
|
||||
def test_customer_not_found_skips_dispatch(
|
||||
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
|
||||
):
|
||||
"""Missing customer does not trigger a send."""
|
||||
mock_customer_svc.get_customer_by_id.return_value = None
|
||||
db = MagicMock()
|
||||
card = _make_card_stub()
|
||||
|
||||
notification_service.send_enrollment_confirmation(db, card)
|
||||
|
||||
mock_task.delay.assert_not_called()
|
||||
Reference in New Issue
Block a user