fix: Update djstripe signal imports and fix test mocking

- Use correct WEBHOOK_SIGNALS dict access for payment intent signals
- Simplify webhook tests by removing complex djstripe module mocking
- Fix TimezoneSerializerMixin tests to expect dynamic field addition
- Update TenantViewSet tests to mock exclude() chain for public schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-10 00:24:37 -05:00
parent 507222316c
commit 2f6ea82114
4 changed files with 41 additions and 46 deletions

View File

@@ -4,38 +4,13 @@ Unit tests for Stripe webhook signal handlers.
Tests webhook signal handling logic with mocks to avoid database calls.
Follows CLAUDE.md guidelines: prefer mocks, avoid @pytest.mark.django_db.
Note: The webhooks.py module uses incorrect signal names (signals.payment_intent_succeeded
instead of signals.WEBHOOK_SIGNALS['payment_intent.succeeded']). These tests work around
this by mocking the signals module before import.
Note: The webhooks.py module uses djstripe signals. These tests mock the
handler functions' dependencies to test their logic in isolation.
"""
from unittest.mock import Mock, patch, MagicMock
import pytest
from decimal import Decimal
import sys
# Create a complete mock of djstripe.signals that matches what webhooks.py expects
class MockSignals:
"""Mock djstripe signals module with attribute-style signal access."""
webhook_processing_error = MagicMock()
payment_intent_succeeded = MagicMock()
payment_intent_payment_failed = MagicMock()
payment_intent_canceled = MagicMock()
WEBHOOK_SIGNALS = {
'payment_intent.succeeded': payment_intent_succeeded,
'payment_intent.payment_failed': payment_intent_payment_failed,
'payment_intent.canceled': payment_intent_canceled,
}
# Mock the djstripe module before any imports
mock_djstripe = MagicMock()
mock_djstripe.signals = MockSignals()
sys.modules['djstripe'] = mock_djstripe
# Now we can safely import the webhooks module
from smoothschedule.commerce.payments import webhooks
from smoothschedule.commerce.payments.models import TransactionLink

View File

@@ -4,7 +4,7 @@ Stripe Webhook Signal Handlers
Listens to dj-stripe signals to update TransactionLink and Event status.
"""
from django.dispatch import receiver
from djstripe import signals
from djstripe.signals import WEBHOOK_SIGNALS, webhook_processing_error
from django.utils import timezone
from .models import TransactionLink
from smoothschedule.scheduling.schedule.models import Event
@@ -13,7 +13,7 @@ import logging
logger = logging.getLogger(__name__)
@receiver(signals.webhook_processing_error)
@receiver(webhook_processing_error)
def handle_webhook_error(sender, exception, event_type, **kwargs):
"""
Log webhook processing errors for debugging.
@@ -31,7 +31,7 @@ def handle_webhook_error(sender, exception, event_type, **kwargs):
)
@receiver(signals.payment_intent_succeeded)
@receiver(WEBHOOK_SIGNALS['payment_intent.succeeded'])
def handle_payment_succeeded(sender, event, **kwargs):
"""
Handle successful payment and update Event status to PAID.
@@ -80,7 +80,7 @@ def handle_payment_succeeded(sender, event, **kwargs):
)
@receiver(signals.payment_intent_payment_failed)
@receiver(WEBHOOK_SIGNALS['payment_intent.payment_failed'])
def handle_payment_failed(sender, event, **kwargs):
"""Handle failed payments"""
payment_intent = event.data.object
@@ -109,7 +109,7 @@ def handle_payment_failed(sender, event, **kwargs):
logger.error(f"Error processing payment_failed: {str(e)}", exc_info=e)
@receiver(signals.payment_intent_canceled)
@receiver(WEBHOOK_SIGNALS['payment_intent.canceled'])
def handle_payment_canceled(sender, event, **kwargs):
"""Handle canceled payments"""
payment_intent = event.data.object

View File

@@ -15,18 +15,23 @@ class TestTimezoneSerializerMixin:
"""Test TimezoneSerializerMixin class."""
def test_adds_business_timezone_field_to_serializer(self):
"""Should add business_timezone as a SerializerMethodField."""
"""Should add business_timezone as a SerializerMethodField when in Meta.fields."""
from smoothschedule.identity.core.mixins import TimezoneSerializerMixin
class TestSerializer(TimezoneSerializerMixin, serializers.Serializer):
name = serializers.CharField()
class Meta:
fields = ['name', 'business_timezone']
# Need to instantiate with context to bind the serializer
serializer = TestSerializer(context={})
# Check that the mixin defines the business_timezone attribute
assert hasattr(TimezoneSerializerMixin, 'business_timezone')
assert isinstance(TimezoneSerializerMixin.business_timezone, serializers.SerializerMethodField)
# Check that the mixin provides the get_business_timezone method
assert hasattr(TimezoneSerializerMixin, 'get_business_timezone')
# Check that business_timezone field is dynamically added when in Meta.fields
assert 'business_timezone' in serializer.fields
assert isinstance(serializer.fields['business_timezone'], serializers.SerializerMethodField)
def test_get_business_timezone_from_context_tenant(self):
"""Should get timezone from tenant in context."""
@@ -258,6 +263,9 @@ class TestTimezoneSerializerMixin:
class TestSerializer(TimezoneSerializerMixin, serializers.Serializer):
name = serializers.CharField()
class Meta:
fields = ['name', 'business_timezone']
# Attempt to create with business_timezone
data = {
'name': 'Test Event',
@@ -270,7 +278,9 @@ class TestTimezoneSerializerMixin:
assert serializer.is_valid()
# The business_timezone field is a SerializerMethodField which is always read-only
assert isinstance(TimezoneSerializerMixin.business_timezone, serializers.SerializerMethodField)
assert isinstance(serializer.fields['business_timezone'], serializers.SerializerMethodField)
# Validated data should not include business_timezone (it's read-only)
assert 'business_timezone' not in serializer.validated_data
class TestTimezoneContextMixin:

View File

@@ -1361,15 +1361,19 @@ class TestTenantViewSet:
request.query_params = {'is_active': 'true'}
mock_queryset = Mock()
excluded_queryset = Mock()
filtered_queryset = Mock()
mock_queryset.filter.return_value = filtered_queryset
# Chain: queryset.exclude().filter()
mock_queryset.exclude.return_value = excluded_queryset
excluded_queryset.filter.return_value = filtered_queryset
with patch.object(self.viewset, 'queryset', mock_queryset):
view = self.viewset()
view.request = request
result = view.get_queryset()
mock_queryset.filter.assert_called_once_with(is_active=True)
mock_queryset.exclude.assert_called_once_with(schema_name='public')
excluded_queryset.filter.assert_called_once_with(is_active=True)
def test_destroy_requires_superuser(self):
"""Test destroy requires superuser role"""
@@ -1424,9 +1428,12 @@ class TestTenantViewSet:
role=User.Role.SUPERUSER
)
with patch('smoothschedule.identity.core.models.Tenant.objects.count', return_value=10):
with patch('smoothschedule.identity.core.models.Tenant.objects.filter') as mock_filter:
mock_filter.return_value.count.return_value = 8
# Mock the Tenant.objects.exclude().count() and .filter().count() chains
with patch('smoothschedule.identity.core.models.Tenant.objects.exclude') as mock_exclude:
mock_excluded = Mock()
mock_exclude.return_value = mock_excluded
mock_excluded.count.return_value = 10 # total_tenants
mock_excluded.filter.return_value.count.return_value = 8 # active_tenants
with patch('smoothschedule.identity.users.models.User.objects.count', return_value=100):
view = self.viewset.as_view({'get': 'metrics'})
response = view(request)
@@ -1435,6 +1442,9 @@ class TestTenantViewSet:
assert 'total_tenants' in response.data
assert 'active_tenants' in response.data
assert 'total_users' in response.data
assert response.data['total_tenants'] == 10
assert response.data['active_tenants'] == 8
assert response.data['total_users'] == 100
# ============================================================================