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>
193 lines
7.6 KiB
Python
193 lines
7.6 KiB
Python
"""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()
|