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:
@@ -0,0 +1,29 @@
|
||||
"""loyalty 005 - add last_expiration_warning_at to loyalty_cards
|
||||
|
||||
Tracks when the last expiration warning email was sent to prevent
|
||||
duplicate notifications. The expiration task checks this timestamp
|
||||
and only sends a warning once per expiration cycle.
|
||||
|
||||
Revision ID: loyalty_005
|
||||
Revises: loyalty_004
|
||||
Create Date: 2026-04-11
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "loyalty_005"
|
||||
down_revision = "loyalty_004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"loyalty_cards",
|
||||
sa.Column("last_expiration_warning_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("loyalty_cards", "last_expiration_warning_at")
|
||||
@@ -223,6 +223,13 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
|
||||
comment="Any activity (for expiration calculation)",
|
||||
)
|
||||
|
||||
# Notification tracking
|
||||
last_expiration_warning_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="When last expiration warning email was sent",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Status
|
||||
# =========================================================================
|
||||
|
||||
@@ -624,6 +624,23 @@ class CardService:
|
||||
|
||||
wallet_service.create_wallet_objects(db, card)
|
||||
|
||||
# Send notification emails (async via Celery)
|
||||
try:
|
||||
from app.modules.loyalty.services.notification_service import (
|
||||
notification_service,
|
||||
)
|
||||
|
||||
notification_service.send_enrollment_confirmation(db, card)
|
||||
if program.welcome_bonus_points > 0:
|
||||
notification_service.send_welcome_bonus(
|
||||
db, card, program.welcome_bonus_points
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Failed to queue enrollment notification for card {card.id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
||||
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
||||
|
||||
137
app/modules/loyalty/services/notification_service.py
Normal file
137
app/modules/loyalty/services/notification_service.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# app/modules/loyalty/services/notification_service.py
|
||||
"""
|
||||
Loyalty notification service.
|
||||
|
||||
Thin wrapper that resolves customer/card/program data into template
|
||||
variables and dispatches emails asynchronously via the Celery task.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoyaltyNotificationService:
|
||||
"""Dispatches loyalty email notifications."""
|
||||
|
||||
def _resolve_context(self, db: Session, card: LoyaltyCard) -> dict | None:
|
||||
"""Load customer, store, and program info for a card.
|
||||
|
||||
Returns None if the customer has no email (can't send).
|
||||
"""
|
||||
from app.modules.customers.services.customer_service import customer_service
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
customer = customer_service.get_customer_by_id(db, card.customer_id)
|
||||
if not customer or not customer.email:
|
||||
return None
|
||||
|
||||
store = store_service.get_store_by_id_optional(db, card.enrolled_at_store_id)
|
||||
program = card.program
|
||||
|
||||
return {
|
||||
"customer": customer,
|
||||
"store": store,
|
||||
"program": program,
|
||||
"to_email": customer.email,
|
||||
"to_name": customer.full_name,
|
||||
"store_id": card.enrolled_at_store_id,
|
||||
"customer_id": customer.id,
|
||||
"customer_name": customer.full_name,
|
||||
"program_name": program.display_name if program else "Loyalty Program",
|
||||
"store_name": store.name if store else "",
|
||||
}
|
||||
|
||||
def _dispatch(
|
||||
self, template_code: str, ctx: dict, extra_vars: dict | None = None
|
||||
):
|
||||
"""Enqueue a notification email via Celery."""
|
||||
from app.modules.loyalty.tasks.notifications import send_notification_email
|
||||
|
||||
variables = {
|
||||
"customer_name": ctx["customer_name"],
|
||||
"program_name": ctx["program_name"],
|
||||
"store_name": ctx["store_name"],
|
||||
}
|
||||
if extra_vars:
|
||||
variables.update(extra_vars)
|
||||
|
||||
send_notification_email.delay(
|
||||
template_code=template_code,
|
||||
to_email=ctx["to_email"],
|
||||
to_name=ctx["to_name"],
|
||||
variables=variables,
|
||||
store_id=ctx["store_id"],
|
||||
customer_id=ctx["customer_id"],
|
||||
)
|
||||
logger.info(
|
||||
f"Queued {template_code} for {ctx['to_email']}"
|
||||
)
|
||||
|
||||
def send_enrollment_confirmation(self, db: Session, card: LoyaltyCard):
|
||||
"""Send enrollment confirmation email."""
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_enrollment", ctx, {
|
||||
"card_number": card.card_number,
|
||||
})
|
||||
|
||||
def send_welcome_bonus(self, db: Session, card: LoyaltyCard, points: int):
|
||||
"""Send welcome bonus notification (only if points > 0)."""
|
||||
if points <= 0:
|
||||
return
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_welcome_bonus", ctx, {
|
||||
"points": str(points),
|
||||
})
|
||||
|
||||
def send_points_expiration_warning(
|
||||
self,
|
||||
db: Session,
|
||||
card: LoyaltyCard,
|
||||
expiring_points: int,
|
||||
days_remaining: int,
|
||||
expiration_date: str,
|
||||
):
|
||||
"""Send points expiring warning email."""
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_points_expiring", ctx, {
|
||||
"points": str(expiring_points),
|
||||
"days_remaining": str(days_remaining),
|
||||
"expiration_date": expiration_date,
|
||||
})
|
||||
|
||||
def send_points_expired(
|
||||
self, db: Session, card: LoyaltyCard, expired_points: int
|
||||
):
|
||||
"""Send points expired notification email."""
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_points_expired", ctx, {
|
||||
"expired_points": str(expired_points),
|
||||
})
|
||||
|
||||
def send_reward_available(
|
||||
self, db: Session, card: LoyaltyCard, reward_name: str
|
||||
):
|
||||
"""Send reward earned notification email."""
|
||||
ctx = self._resolve_context(db, card)
|
||||
if not ctx:
|
||||
return
|
||||
self._dispatch("loyalty_reward_ready", ctx, {
|
||||
"reward_name": reward_name,
|
||||
})
|
||||
|
||||
|
||||
# Singleton
|
||||
notification_service = LoyaltyNotificationService()
|
||||
@@ -162,6 +162,22 @@ class StampService:
|
||||
|
||||
wallet_service.sync_card_to_wallets(db, card)
|
||||
|
||||
# Notify customer when they've earned a reward
|
||||
if reward_earned:
|
||||
try:
|
||||
from app.modules.loyalty.services.notification_service import (
|
||||
notification_service,
|
||||
)
|
||||
|
||||
notification_service.send_reward_available(
|
||||
db, card, program.stamps_reward_description or "Reward"
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Failed to queue reward notification for card {card.id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
stamps_today += 1
|
||||
|
||||
logger.info(
|
||||
|
||||
72
app/modules/loyalty/tasks/notifications.py
Normal file
72
app/modules/loyalty/tasks/notifications.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# app/modules/loyalty/tasks/notifications.py
|
||||
"""
|
||||
Async email notification dispatch for loyalty events.
|
||||
|
||||
All loyalty notification emails are sent asynchronously via this Celery
|
||||
task to avoid blocking request handlers. The task opens its own DB
|
||||
session and calls EmailService.send_template() which handles language
|
||||
resolution, store overrides, Jinja2 rendering, and EmailLog creation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="loyalty.send_notification_email",
|
||||
bind=True,
|
||||
max_retries=3,
|
||||
default_retry_delay=60,
|
||||
)
|
||||
def send_notification_email(
|
||||
self,
|
||||
template_code: str,
|
||||
to_email: str,
|
||||
to_name: str | None = None,
|
||||
variables: dict | None = None,
|
||||
store_id: int | None = None,
|
||||
customer_id: int | None = None,
|
||||
language: str | None = None,
|
||||
):
|
||||
"""
|
||||
Send a loyalty notification email asynchronously.
|
||||
|
||||
Args:
|
||||
template_code: Email template code (e.g. 'loyalty_enrollment')
|
||||
to_email: Recipient email address
|
||||
to_name: Recipient display name
|
||||
variables: Template variables dict
|
||||
store_id: Store ID for branding and template overrides
|
||||
customer_id: Customer ID for language resolution
|
||||
language: Explicit language override (otherwise auto-resolved)
|
||||
"""
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.messaging.services.email_service import EmailService
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
email_service = EmailService(db)
|
||||
email_log = email_service.send_template(
|
||||
template_code=template_code,
|
||||
to_email=to_email,
|
||||
to_name=to_name,
|
||||
language=language,
|
||||
variables=variables or {},
|
||||
store_id=store_id,
|
||||
customer_id=customer_id,
|
||||
)
|
||||
logger.info(
|
||||
f"Loyalty notification sent: {template_code} to {to_email} "
|
||||
f"(log_id={email_log.id if email_log else 'none'})"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"Loyalty notification failed: {template_code} to {to_email}: {exc}"
|
||||
)
|
||||
db.rollback()
|
||||
raise self.retry(exc=exc)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -109,6 +109,9 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
||||
"""
|
||||
Expire points for a specific loyalty program.
|
||||
|
||||
Also sends warning emails to cards approaching their expiration date
|
||||
(14 days before) and expired notifications after points are zeroed.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
program: Loyalty program to process
|
||||
@@ -119,14 +122,26 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
||||
if not program.points_expiration_days:
|
||||
return 0, 0
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Calculate expiration threshold
|
||||
expiration_threshold = datetime.now(UTC) - timedelta(days=program.points_expiration_days)
|
||||
expiration_threshold = now - timedelta(days=program.points_expiration_days)
|
||||
|
||||
logger.debug(
|
||||
f"Processing program {program.id}: expiration after {program.points_expiration_days} days "
|
||||
f"(threshold: {expiration_threshold})"
|
||||
)
|
||||
|
||||
# --- Phase 1: Send 14-day warning emails ---
|
||||
warning_days = 14
|
||||
warning_threshold = now - timedelta(
|
||||
days=program.points_expiration_days - warning_days
|
||||
)
|
||||
_send_expiration_warnings(
|
||||
db, program, warning_threshold, warning_days, now
|
||||
)
|
||||
|
||||
# --- Phase 2: Expire points ---
|
||||
# Find cards with:
|
||||
# - Points balance > 0
|
||||
# - Last activity before expiration threshold
|
||||
@@ -168,7 +183,7 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
||||
stamps_delta=0,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
notes=f"Points expired after {program.points_expiration_days} days of inactivity",
|
||||
transaction_at=datetime.now(UTC),
|
||||
transaction_at=now,
|
||||
)
|
||||
db.add(transaction) # noqa: PERF006
|
||||
|
||||
@@ -176,6 +191,19 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
||||
card.expire_points(expired_points)
|
||||
# Note: We don't update last_activity_at for expiration
|
||||
|
||||
# Send expired notification
|
||||
try:
|
||||
from app.modules.loyalty.services.notification_service import (
|
||||
notification_service,
|
||||
)
|
||||
|
||||
notification_service.send_points_expired(db, card, expired_points)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Failed to queue expiration notification for card {card.id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
cards_processed += 1
|
||||
points_expired += expired_points
|
||||
|
||||
@@ -187,6 +215,81 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
|
||||
return cards_processed, points_expired
|
||||
|
||||
|
||||
def _send_expiration_warnings(
|
||||
db: Session,
|
||||
program: LoyaltyProgram,
|
||||
warning_threshold: datetime,
|
||||
warning_days: int,
|
||||
now: datetime,
|
||||
) -> int:
|
||||
"""Send expiration warning emails to cards approaching expiry.
|
||||
|
||||
Only sends one warning per expiration cycle (tracked via
|
||||
last_expiration_warning_at on the card).
|
||||
|
||||
Returns:
|
||||
Number of warnings sent
|
||||
"""
|
||||
from sqlalchemy import or_
|
||||
|
||||
# Find cards in the warning window:
|
||||
# - Have points
|
||||
# - Last activity is past the warning threshold (i.e. will expire in ~14 days)
|
||||
# - But NOT yet past the full expiration threshold
|
||||
# - Haven't received a warning yet in this cycle
|
||||
expiration_threshold = now - timedelta(days=program.points_expiration_days)
|
||||
|
||||
cards = (
|
||||
db.query(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.merchant_id == program.merchant_id,
|
||||
LoyaltyCard.points_balance > 0,
|
||||
LoyaltyCard.is_active == True,
|
||||
LoyaltyCard.last_activity_at < warning_threshold,
|
||||
LoyaltyCard.last_activity_at >= expiration_threshold,
|
||||
or_(
|
||||
LoyaltyCard.last_expiration_warning_at.is_(None),
|
||||
LoyaltyCard.last_expiration_warning_at < warning_threshold,
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not cards:
|
||||
return 0
|
||||
|
||||
warnings_sent = 0
|
||||
expiration_date = (
|
||||
now + timedelta(days=warning_days)
|
||||
).strftime("%Y-%m-%d")
|
||||
|
||||
for card in cards:
|
||||
try:
|
||||
from app.modules.loyalty.services.notification_service import (
|
||||
notification_service,
|
||||
)
|
||||
|
||||
notification_service.send_points_expiration_warning(
|
||||
db,
|
||||
card,
|
||||
expiring_points=card.points_balance,
|
||||
days_remaining=warning_days,
|
||||
expiration_date=expiration_date,
|
||||
)
|
||||
card.last_expiration_warning_at = now
|
||||
warnings_sent += 1
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Failed to queue expiration warning for card {card.id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sent {warnings_sent} expiration warnings for program {program.id}"
|
||||
)
|
||||
return warnings_sent
|
||||
|
||||
|
||||
# Allow running directly for testing
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
@@ -19,6 +19,16 @@ import pytest
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_rate_limiter():
|
||||
"""Reset the in-memory rate limiter before each test to prevent bleed."""
|
||||
from middleware.decorators import rate_limiter
|
||||
|
||||
rate_limiter.clients.clear()
|
||||
yield
|
||||
rate_limiter.clients.clear()
|
||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
@@ -467,26 +477,30 @@ class TestStampEarnRedeem:
|
||||
self, client, stamp_store_headers, stamp_store_setup
|
||||
):
|
||||
"""POST /stamp returns 429 once the per-IP cap is exceeded."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from middleware.decorators import rate_limiter
|
||||
|
||||
# Reset the in-memory limiter so prior tests don't bleed in
|
||||
rate_limiter.clients.clear()
|
||||
|
||||
card = stamp_store_setup["card"]
|
||||
# Cap is 60 per minute. Hit it 60 times and expect any 200/4xx but not
|
||||
# a 429, then the 61st should be 429.
|
||||
for _ in range(60):
|
||||
client.post(
|
||||
# Mock notification dispatch so 60 requests complete fast enough
|
||||
# to stay within the rate limiter's 60-second window.
|
||||
with patch(
|
||||
"app.modules.loyalty.tasks.notifications.send_notification_email"
|
||||
):
|
||||
for _ in range(60):
|
||||
client.post(
|
||||
f"{BASE}/stamp",
|
||||
json={"card_id": card.id},
|
||||
headers=stamp_store_headers,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/stamp",
|
||||
json={"card_id": card.id},
|
||||
headers=stamp_store_headers,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/stamp",
|
||||
json={"card_id": card.id},
|
||||
headers=stamp_store_headers,
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
rate_limiter.clients.clear()
|
||||
|
||||
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