feat(loyalty): Phase 2A — transactional email notifications
Some checks failed
CI / ruff (push) Successful in 13s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

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:
2026-04-11 19:11:56 +02:00
parent f804ff8442
commit 52b78ce346
11 changed files with 1097 additions and 13 deletions

View 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()