mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
🐛(retries) make message send retries more reliable (#386)
Adds a comprehensive test and a fix for failing "send" tasks
This commit is contained in:
@@ -1,159 +0,0 @@
|
|||||||
"""Management command to retry sending a message to failed/retry recipients."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from core import models
|
|
||||||
from core.enums import MessageDeliveryStatusChoices
|
|
||||||
from core.mda.tasks import retry_messages_task
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
"""Management command to retry sending message(s) to recipients with retry status."""
|
|
||||||
|
|
||||||
help = "Retry sending message(s) to recipients with retry status. Without --force, delegates to celery task (respects retry timing). With --force, processes immediately (ignores retry delays). Specify message_id for single message, or omit for bulk processing."
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
"""Define optional argument for message ID."""
|
|
||||||
parser.add_argument(
|
|
||||||
"message_id",
|
|
||||||
nargs="?",
|
|
||||||
help="ID of the message to retry sending (if not provided, retry all retryable messages)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--force-mta-out",
|
|
||||||
action="store_true",
|
|
||||||
help="Force sending through external MTA even for local recipients",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--batch-size",
|
|
||||||
type=int,
|
|
||||||
default=100,
|
|
||||||
help="Number of messages to process in each batch (default: 100)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--force",
|
|
||||||
action="store_true",
|
|
||||||
help="Force immediate retry by resetting retry_at timestamps (ignores retry delays)",
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
"""
|
|
||||||
Retry sending messages to recipients with retry status.
|
|
||||||
|
|
||||||
Without --force: Delegates to celery task (respects retry timing).
|
|
||||||
With --force: Processes immediately (resets retry_at timestamps).
|
|
||||||
"""
|
|
||||||
message_id = options.get("message_id")
|
|
||||||
force_mta_out = options.get("force_mta_out", False)
|
|
||||||
batch_size = options.get("batch_size", 100)
|
|
||||||
force = options.get("force", False)
|
|
||||||
|
|
||||||
if force:
|
|
||||||
# Handle force operations: reset timestamps and delegate to celery task
|
|
||||||
if message_id:
|
|
||||||
# Reset timestamps for single message and delegate
|
|
||||||
self._reset_and_delegate_single(message_id, force_mta_out)
|
|
||||||
else:
|
|
||||||
# Reset timestamps for all messages and delegate
|
|
||||||
self._reset_and_delegate_all(force_mta_out, batch_size)
|
|
||||||
else:
|
|
||||||
# Delegate to celery task for non-force operations
|
|
||||||
self._delegate_to_celery_task(message_id, force_mta_out, batch_size)
|
|
||||||
|
|
||||||
def _delegate_to_celery_task(self, message_id, force_mta_out, batch_size):
|
|
||||||
"""Delegate retry operations to celery task synchronously and print result."""
|
|
||||||
self.stdout.write("Running retry operations via celery task (synchronously)...")
|
|
||||||
|
|
||||||
result = retry_messages_task.apply(
|
|
||||||
args=(),
|
|
||||||
kwargs={
|
|
||||||
"message_id": message_id,
|
|
||||||
"force_mta_out": force_mta_out,
|
|
||||||
"batch_size": batch_size,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if result.successful():
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f"Task completed successfully: {result.get()}")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.stdout.write(self.style.ERROR(f"Task failed: {result.result}"))
|
|
||||||
|
|
||||||
def _reset_and_delegate_single(self, message_id, force_mta_out):
|
|
||||||
"""Reset retry_at timestamps for single message and delegate to celery task."""
|
|
||||||
try:
|
|
||||||
message = models.Message.objects.get(id=message_id)
|
|
||||||
except models.Message.DoesNotExist:
|
|
||||||
raise CommandError(
|
|
||||||
f"Message with ID '{message_id}' does not exist."
|
|
||||||
) from None
|
|
||||||
|
|
||||||
# Check if message is a draft
|
|
||||||
if message.is_draft:
|
|
||||||
raise CommandError(
|
|
||||||
f"Message '{message_id}' is still a draft and cannot be sent."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get recipients with retry status
|
|
||||||
retry_recipients = message.recipients.filter(
|
|
||||||
delivery_status__in=[
|
|
||||||
MessageDeliveryStatusChoices.RETRY,
|
|
||||||
# MessageDeliveryStatusChoices.FAILED,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if not retry_recipients.exists():
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.WARNING(
|
|
||||||
f"No recipients with retry status found for message '{message_id}'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Reset retry_at timestamps
|
|
||||||
with transaction.atomic():
|
|
||||||
updated_count = retry_recipients.update(retry_at=None)
|
|
||||||
self.stdout.write(
|
|
||||||
f"Reset retry_at timestamp for {updated_count} recipient(s) of message '{message_id}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delegate to celery task
|
|
||||||
self._delegate_to_celery_task(message_id, force_mta_out, 100)
|
|
||||||
|
|
||||||
def _reset_and_delegate_all(self, force_mta_out, batch_size):
|
|
||||||
"""Reset retry_at timestamps for all messages and delegate to celery task."""
|
|
||||||
# Find all messages with retryable recipients
|
|
||||||
messages_with_retries = models.Message.objects.filter(
|
|
||||||
is_draft=False,
|
|
||||||
recipients__delivery_status=MessageDeliveryStatusChoices.RETRY,
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
total_messages = messages_with_retries.count()
|
|
||||||
|
|
||||||
if total_messages == 0:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.WARNING("No messages with retryable recipients found")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
f"Found {total_messages} message(s) with retryable recipients"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reset retry_at timestamps for all recipients
|
|
||||||
with transaction.atomic():
|
|
||||||
updated_count = models.MessageRecipient.objects.filter(
|
|
||||||
delivery_status=MessageDeliveryStatusChoices.RETRY
|
|
||||||
).update(retry_at=None)
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
f"Reset retry_at timestamp for {updated_count} recipient(s) across all messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delegate to celery task
|
|
||||||
self._delegate_to_celery_task(None, force_mta_out, batch_size)
|
|
||||||
@@ -232,6 +232,12 @@ def send_message(message: models.Message, force_mta_out: bool = False):
|
|||||||
This part is called asynchronously from the celery worker.
|
This part is called asynchronously from the celery worker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Refuse to send messages that are draft or not senders
|
||||||
|
if message.is_draft:
|
||||||
|
raise ValueError("Cannot send a draft message")
|
||||||
|
if not message.is_sender:
|
||||||
|
raise ValueError("Cannot send a message we are not sender of")
|
||||||
|
|
||||||
# Create a unique lock key for this message to prevent double sends
|
# Create a unique lock key for this message to prevent double sends
|
||||||
lock_key = f"send_message_lock:{message.id}"
|
lock_key = f"send_message_lock:{message.id}"
|
||||||
lock_timeout = 1800 # 30 minutes timeout for the lock
|
lock_timeout = 1800 # 30 minutes timeout for the lock
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ def send_message_task(self, message_id, force_mta_out=False, must_archive=False)
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_id: The ID of the message to send
|
message_id: The ID of the message to send
|
||||||
mime_data: The MIME data dictionary
|
|
||||||
force_mta_out: Whether to force sending via MTA
|
force_mta_out: Whether to force sending via MTA
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -149,12 +148,19 @@ def retry_messages_task(self, message_id=None, force_mta_out=False, batch_size=1
|
|||||||
total_messages = 1
|
total_messages = 1
|
||||||
else:
|
else:
|
||||||
# Bulk mode - find all messages with retryable recipients that are ready for retry
|
# Bulk mode - find all messages with retryable recipients that are ready for retry
|
||||||
message_filter_q = Q(
|
message_filter_q = (
|
||||||
is_draft=False,
|
Q(
|
||||||
recipients__delivery_status=MessageDeliveryStatusChoices.RETRY,
|
is_draft=False,
|
||||||
) & (
|
is_sender=True,
|
||||||
Q(recipients__retry_at__isnull=True)
|
)
|
||||||
| Q(recipients__retry_at__lte=timezone.now())
|
& (
|
||||||
|
Q(recipients__delivery_status=MessageDeliveryStatusChoices.RETRY)
|
||||||
|
| Q(recipients__delivery_status__isnull=True)
|
||||||
|
)
|
||||||
|
& (
|
||||||
|
Q(recipients__retry_at__isnull=True)
|
||||||
|
| Q(recipients__retry_at__lte=timezone.now())
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
messages_to_process = list(
|
messages_to_process = list(
|
||||||
@@ -197,10 +203,10 @@ def retry_messages_task(self, message_id=None, force_mta_out=False, batch_size=1
|
|||||||
for message in batch_messages:
|
for message in batch_messages:
|
||||||
try:
|
try:
|
||||||
# Get recipients with retry status that are ready for retry
|
# Get recipients with retry status that are ready for retry
|
||||||
retry_filter_q = Q(
|
retry_filter_q = (
|
||||||
delivery_status=MessageDeliveryStatusChoices.RETRY
|
Q(delivery_status=MessageDeliveryStatusChoices.RETRY)
|
||||||
|
| Q(delivery_status__isnull=True)
|
||||||
) & (Q(retry_at__isnull=True) | Q(retry_at__lte=timezone.now()))
|
) & (Q(retry_at__isnull=True) | Q(retry_at__lte=timezone.now()))
|
||||||
|
|
||||||
retry_recipients = message.recipients.filter(retry_filter_q)
|
retry_recipients = message.recipients.filter(retry_filter_q)
|
||||||
|
|
||||||
if retry_recipients.exists():
|
if retry_recipients.exists():
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class TestSendOutboundMessage:
|
|||||||
thread=thread,
|
thread=thread,
|
||||||
sender=sender_contact,
|
sender=sender_contact,
|
||||||
is_draft=False,
|
is_draft=False,
|
||||||
|
is_sender=True,
|
||||||
subject="Test Outbound",
|
subject="Test Outbound",
|
||||||
)
|
)
|
||||||
# Create a blob with the raw MIME content
|
# Create a blob with the raw MIME content
|
||||||
@@ -466,6 +467,7 @@ class TestSendMessageRedisLock(TransactionTestCase):
|
|||||||
thread=self.thread,
|
thread=self.thread,
|
||||||
sender=self.sender_contact,
|
sender=self.sender_contact,
|
||||||
is_draft=False,
|
is_draft=False,
|
||||||
|
is_sender=True,
|
||||||
subject="Test Lock",
|
subject="Test Lock",
|
||||||
)
|
)
|
||||||
# Create a blob with the raw MIME content
|
# Create a blob with the raw MIME content
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
"""Tests for the core.mda.tasks retry functionality."""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import enums, factories, models
|
||||||
|
from core.mda import tasks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestRetryMessagesTask:
|
||||||
|
"""Unit tests for the retry_messages_task function."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mailbox_sender(self):
|
||||||
|
"""Create a test mailbox sender."""
|
||||||
|
return factories.MailboxFactory()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def thread(self, mailbox_sender):
|
||||||
|
"""Create a test thread."""
|
||||||
|
thread = factories.ThreadFactory()
|
||||||
|
factories.ThreadAccessFactory(
|
||||||
|
mailbox=mailbox_sender,
|
||||||
|
thread=thread,
|
||||||
|
role=enums.ThreadAccessRoleChoices.EDITOR,
|
||||||
|
)
|
||||||
|
return thread
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def message_with_recipients(self, mailbox_sender, thread):
|
||||||
|
"""Create a message with recipients in various delivery states."""
|
||||||
|
sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
|
||||||
|
message = factories.MessageFactory(
|
||||||
|
thread=thread,
|
||||||
|
sender=sender_contact,
|
||||||
|
is_draft=False,
|
||||||
|
is_sender=True,
|
||||||
|
subject="Test Retry Message",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create recipients with different delivery statuses
|
||||||
|
to_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="to@example.com"
|
||||||
|
)
|
||||||
|
cc_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="cc@example.com"
|
||||||
|
)
|
||||||
|
bcc_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="bcc@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recipient with RETRY status
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=to_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.TO,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.RETRY,
|
||||||
|
retry_at=timezone.now() - timezone.timedelta(minutes=1), # Ready for retry
|
||||||
|
retry_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recipient with null delivery_status (failed mid-route)
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=cc_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.CC,
|
||||||
|
delivery_status=None, # This simulates prepare_message() done but no send
|
||||||
|
retry_at=None,
|
||||||
|
retry_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recipient with SENT status (should not be retried)
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=bcc_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.BCC,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.SENT,
|
||||||
|
delivered_at=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def draft_message(self, mailbox_sender, thread):
|
||||||
|
"""Create a draft message (should not be retryable)."""
|
||||||
|
sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
|
||||||
|
message = factories.MessageFactory(
|
||||||
|
thread=thread,
|
||||||
|
sender=sender_contact,
|
||||||
|
is_draft=True, # Still a draft
|
||||||
|
is_sender=True,
|
||||||
|
subject="Draft Message",
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
@patch("core.mda.tasks.send_message")
|
||||||
|
def test_retry_single_message_success(
|
||||||
|
self, mock_send_message, message_with_recipients
|
||||||
|
):
|
||||||
|
"""Test retrying a single message by ID."""
|
||||||
|
message = message_with_recipients
|
||||||
|
|
||||||
|
# Mock successful send
|
||||||
|
mock_send_message.return_value = None
|
||||||
|
|
||||||
|
result = tasks.retry_messages_task.apply(args=[str(message.id)]).get()
|
||||||
|
|
||||||
|
# Verify the result
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["message_id"] == str(message.id)
|
||||||
|
assert result["success_count"] == 1
|
||||||
|
assert result["error_count"] == 0
|
||||||
|
assert result["processed_messages"] == 1
|
||||||
|
|
||||||
|
# Verify send_message was called
|
||||||
|
mock_send_message.assert_called_once_with(message, force_mta_out=False)
|
||||||
|
|
||||||
|
def test_retry_nonexistent_message(self):
|
||||||
|
"""Test retrying a non-existent message."""
|
||||||
|
fake_message_id = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
result = tasks.retry_messages_task.apply(args=[fake_message_id]).get()
|
||||||
|
|
||||||
|
# Verify the result
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "does not exist" in result["error"]
|
||||||
|
|
||||||
|
def test_retry_draft_message(self, draft_message):
|
||||||
|
"""Test retrying a draft message (should fail)."""
|
||||||
|
result = tasks.retry_messages_task.apply(args=[str(draft_message.id)]).get()
|
||||||
|
|
||||||
|
# Verify the result
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "is still a draft" in result["error"]
|
||||||
|
|
||||||
|
@patch("core.mda.tasks.send_message")
|
||||||
|
def test_retry_bulk_mode(self, mock_send_message, message_with_recipients):
|
||||||
|
"""Test retrying messages in bulk mode (no message_id specified)."""
|
||||||
|
message = message_with_recipients
|
||||||
|
|
||||||
|
# Mock successful send
|
||||||
|
mock_send_message.return_value = None
|
||||||
|
|
||||||
|
result = tasks.retry_messages_task.apply().get()
|
||||||
|
|
||||||
|
# Verify the result
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["total_messages"] == 1
|
||||||
|
assert result["success_count"] == 1
|
||||||
|
assert result["error_count"] == 0
|
||||||
|
assert result["processed_messages"] == 1
|
||||||
|
|
||||||
|
# Verify send_message was called
|
||||||
|
mock_send_message.assert_called_once_with(message, force_mta_out=False)
|
||||||
|
|
||||||
|
def test_retry_no_messages_ready(self, mailbox_sender, thread):
|
||||||
|
"""Test retry when no messages are ready for retry."""
|
||||||
|
sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
|
||||||
|
message = factories.MessageFactory(
|
||||||
|
thread=thread,
|
||||||
|
sender=sender_contact,
|
||||||
|
is_draft=False,
|
||||||
|
is_sender=True,
|
||||||
|
subject="No Retry Message",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create recipients that are not ready for retry
|
||||||
|
to_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="to@example.com"
|
||||||
|
)
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=to_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.TO,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.RETRY,
|
||||||
|
retry_at=timezone.now() + timezone.timedelta(hours=1), # Not ready yet
|
||||||
|
retry_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tasks.retry_messages_task.apply().get()
|
||||||
|
|
||||||
|
# Verify the result
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["total_messages"] == 0
|
||||||
|
assert result["processed_messages"] == 0
|
||||||
|
assert result["success_count"] == 0
|
||||||
|
assert result["error_count"] == 0
|
||||||
|
assert "No messages ready for retry" in result["message"]
|
||||||
|
|
||||||
|
@patch("core.mda.tasks.send_message")
|
||||||
|
def test_retry_failed_send_task_mid_route(
|
||||||
|
self, mock_send_message, mailbox_sender, thread
|
||||||
|
):
|
||||||
|
"""Test retry when send_message_task() failed mid-route (null delivery_status)."""
|
||||||
|
sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
|
||||||
|
message = factories.MessageFactory(
|
||||||
|
thread=thread,
|
||||||
|
sender=sender_contact,
|
||||||
|
is_draft=False,
|
||||||
|
is_sender=True,
|
||||||
|
sent_at=timezone.now() - timezone.timedelta(minutes=1),
|
||||||
|
subject="Failed Mid-Route Message",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create recipients with null delivery_status (simulating prepare_message() done but no send)
|
||||||
|
to_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="to@example.com"
|
||||||
|
)
|
||||||
|
cc_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="cc@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=to_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.TO,
|
||||||
|
delivery_status=None, # Null status - prepare_message() done but no send
|
||||||
|
retry_at=None,
|
||||||
|
retry_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=cc_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.CC,
|
||||||
|
delivery_status=None, # Null status - prepare_message() done but no send
|
||||||
|
retry_at=None,
|
||||||
|
retry_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock successful send
|
||||||
|
mock_send_message.return_value = None
|
||||||
|
|
||||||
|
result = tasks.retry_messages_task.apply(args=[str(message.id)]).get()
|
||||||
|
|
||||||
|
# Verify the result
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["message_id"] == str(message.id)
|
||||||
|
assert result["success_count"] == 1
|
||||||
|
assert result["error_count"] == 0
|
||||||
|
|
||||||
|
# Verify send_message was called
|
||||||
|
mock_send_message.assert_called_once_with(message, force_mta_out=False)
|
||||||
|
|
||||||
|
@patch("core.mda.tasks.send_message")
|
||||||
|
def test_retry_timing_respect(self, mock_send_message, mailbox_sender, thread):
|
||||||
|
"""Test that retry respects retry timing (retry_at field)."""
|
||||||
|
sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
|
||||||
|
message = factories.MessageFactory(
|
||||||
|
thread=thread,
|
||||||
|
sender=sender_contact,
|
||||||
|
is_draft=False,
|
||||||
|
is_sender=True,
|
||||||
|
subject="Timing Test Message",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create recipients with different retry timing
|
||||||
|
ready_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="ready@example.com"
|
||||||
|
)
|
||||||
|
not_ready_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="notready@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recipient ready for retry
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=ready_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.TO,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.RETRY,
|
||||||
|
retry_at=timezone.now() - timezone.timedelta(minutes=1), # Ready
|
||||||
|
retry_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recipient not ready for retry yet
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=not_ready_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.CC,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.RETRY,
|
||||||
|
retry_at=timezone.now() + timezone.timedelta(hours=1), # Not ready yet
|
||||||
|
retry_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock successful send
|
||||||
|
mock_send_message.return_value = None
|
||||||
|
|
||||||
|
result = tasks.retry_messages_task.apply(args=[str(message.id)]).get()
|
||||||
|
|
||||||
|
# Verify the result - should only process the ready recipient
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["success_count"] == 1
|
||||||
|
|
||||||
|
# Verify send_message was called
|
||||||
|
mock_send_message.assert_called_once_with(message, force_mta_out=False)
|
||||||
|
|
||||||
|
@patch("core.mda.tasks.send_message")
|
||||||
|
def test_retry_batch_processing(self, mock_send_message, mailbox_sender, thread):
|
||||||
|
"""Test retry batch processing functionality."""
|
||||||
|
# Create multiple messages
|
||||||
|
messages = []
|
||||||
|
for i in range(5):
|
||||||
|
sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
|
||||||
|
message = factories.MessageFactory(
|
||||||
|
thread=thread,
|
||||||
|
sender=sender_contact,
|
||||||
|
is_draft=False,
|
||||||
|
is_sender=True,
|
||||||
|
subject=f"Batch Test Message {i}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add recipients ready for retry
|
||||||
|
to_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email=f"to{i}@example.com"
|
||||||
|
)
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=to_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.TO,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.RETRY,
|
||||||
|
retry_at=timezone.now() - timezone.timedelta(minutes=1),
|
||||||
|
retry_count=1,
|
||||||
|
)
|
||||||
|
messages.append(message)
|
||||||
|
|
||||||
|
# Mock successful send
|
||||||
|
mock_send_message.return_value = None
|
||||||
|
|
||||||
|
result = tasks.retry_messages_task.apply(
|
||||||
|
kwargs={"batch_size": 2}
|
||||||
|
).get() # Process in batches of 2
|
||||||
|
|
||||||
|
# Verify the result
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["total_messages"] == 5
|
||||||
|
assert result["success_count"] == 5
|
||||||
|
assert result["error_count"] == 0
|
||||||
|
assert result["processed_messages"] == 5
|
||||||
|
|
||||||
|
# Verify send_message was called for each message
|
||||||
|
assert mock_send_message.call_count == 5
|
||||||
|
|
||||||
|
@patch("core.mda.tasks.send_message")
|
||||||
|
def test_retry_mixed_recipient_statuses(
|
||||||
|
self, mock_send_message, mailbox_sender, thread
|
||||||
|
):
|
||||||
|
"""Test retry with recipients in various delivery states."""
|
||||||
|
sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
|
||||||
|
message = factories.MessageFactory(
|
||||||
|
thread=thread,
|
||||||
|
sender=sender_contact,
|
||||||
|
is_draft=False,
|
||||||
|
is_sender=True,
|
||||||
|
subject="Mixed Status Message",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create recipients with different statuses
|
||||||
|
retry_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="retry@example.com"
|
||||||
|
)
|
||||||
|
null_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="null@example.com"
|
||||||
|
)
|
||||||
|
sent_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="sent@example.com"
|
||||||
|
)
|
||||||
|
failed_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="failed@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
# RETRY status - ready for retry
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=retry_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.TO,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.RETRY,
|
||||||
|
retry_at=timezone.now() - timezone.timedelta(minutes=1),
|
||||||
|
retry_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# NULL status - failed mid-route
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=null_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.CC,
|
||||||
|
delivery_status=None,
|
||||||
|
retry_at=None,
|
||||||
|
retry_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# SENT status - should not be retried
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=sent_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.CC,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.SENT,
|
||||||
|
delivered_at=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# FAILED status - should not be retried
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=failed_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.BCC,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.FAILED,
|
||||||
|
delivery_message="Permanent failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock successful send
|
||||||
|
mock_send_message.return_value = None
|
||||||
|
|
||||||
|
result = tasks.retry_messages_task.apply(args=[str(message.id)]).get()
|
||||||
|
|
||||||
|
# Verify the result - should process 2 recipients (RETRY and NULL)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["message_id"] == str(message.id)
|
||||||
|
assert result["success_count"] == 1 # One message processed successfully
|
||||||
|
assert result["error_count"] == 0
|
||||||
|
assert result["processed_messages"] == 1
|
||||||
|
|
||||||
|
# Verify send_message was called
|
||||||
|
mock_send_message.assert_called_once_with(message, force_mta_out=False)
|
||||||
|
|
||||||
|
@patch("core.mda.tasks.send_message")
|
||||||
|
def test_retry_message_with_no_retryable_recipients(
|
||||||
|
self, mock_send_message, mailbox_sender, thread
|
||||||
|
):
|
||||||
|
"""Test retry when message has no recipients ready for retry."""
|
||||||
|
sender_contact = factories.ContactFactory(mailbox=mailbox_sender)
|
||||||
|
message = factories.MessageFactory(
|
||||||
|
thread=thread,
|
||||||
|
sender=sender_contact,
|
||||||
|
is_draft=False,
|
||||||
|
is_sender=True,
|
||||||
|
subject="No Retryable Recipients Message",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create recipients that are not retryable
|
||||||
|
sent_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="sent@example.com"
|
||||||
|
)
|
||||||
|
failed_contact = factories.ContactFactory(
|
||||||
|
mailbox=mailbox_sender, email="failed@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
# SENT status - should not be retried
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=sent_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.TO,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.SENT,
|
||||||
|
delivered_at=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# FAILED status - should not be retried
|
||||||
|
factories.MessageRecipientFactory(
|
||||||
|
message=message,
|
||||||
|
contact=failed_contact,
|
||||||
|
type=models.MessageRecipientTypeChoices.CC,
|
||||||
|
delivery_status=enums.MessageDeliveryStatusChoices.FAILED,
|
||||||
|
delivery_message="Permanent failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tasks.retry_messages_task.apply(args=[str(message.id)]).get()
|
||||||
|
|
||||||
|
# Verify the result - should process the message but not call send_message
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["message_id"] == str(message.id)
|
||||||
|
assert result["success_count"] == 0 # No recipients to retry
|
||||||
|
assert result["error_count"] == 0
|
||||||
|
assert result["processed_messages"] == 1
|
||||||
|
|
||||||
|
# Verify send_message was NOT called because no recipients were retryable
|
||||||
|
mock_send_message.assert_not_called()
|
||||||
Reference in New Issue
Block a user