- Fix admin tier change: resolve tier_code→tier_id in update_subscription(), delegate to billing_service.change_tier() for Stripe-connected subs - Add platform support to admin tiers page: platform column, filter dropdown, platform selector in create/edit modal, platform_name in tier API response - Filter used platforms in create subscription modal on merchant detail page - Enrich merchant portal API responses with tier code, tier_name, platform_name - Add eager-load of platform relationship in get_merchant_subscription() - Remove stale store_name/store_code references from merchant templates - Add merchant tier change endpoint (POST /change-tier) and tier selector UI replacing broken requestUpgrade() button - Fix subscription detail link to use platform_id instead of sub.id Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
588 lines
21 KiB
Python
588 lines
21 KiB
Python
# tests/unit/services/test_messaging_service.py
|
|
"""Unit tests for MessagingService."""
|
|
|
|
import pytest
|
|
|
|
from app.modules.messaging.services.messaging_service import MessagingService
|
|
from app.modules.messaging.models import (
|
|
Conversation,
|
|
ConversationParticipant,
|
|
ConversationType,
|
|
Message,
|
|
ParticipantType,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def messaging_service():
|
|
"""Create a MessagingService instance."""
|
|
return MessagingService()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessagingServiceCreateConversation:
|
|
"""Test conversation creation."""
|
|
|
|
def test_create_conversation_admin_store(
|
|
self, db, messaging_service, test_admin, test_store_user, test_store
|
|
):
|
|
"""Test creating an admin-store conversation."""
|
|
conversation = messaging_service.create_conversation(
|
|
db=db,
|
|
conversation_type=ConversationType.ADMIN_STORE,
|
|
subject="Test Subject",
|
|
initiator_type=ParticipantType.ADMIN,
|
|
initiator_id=test_admin.id,
|
|
recipient_type=ParticipantType.STORE,
|
|
recipient_id=test_store_user.id,
|
|
store_id=test_store.id,
|
|
)
|
|
db.commit()
|
|
|
|
assert conversation.id is not None
|
|
assert conversation.conversation_type == ConversationType.ADMIN_STORE
|
|
assert conversation.subject == "Test Subject"
|
|
assert conversation.store_id == test_store.id
|
|
assert conversation.is_closed is False
|
|
assert len(conversation.participants) == 2
|
|
|
|
def test_create_conversation_store_customer(
|
|
self, db, messaging_service, test_store_user, test_customer, test_store
|
|
):
|
|
"""Test creating a store-customer conversation."""
|
|
conversation = messaging_service.create_conversation(
|
|
db=db,
|
|
conversation_type=ConversationType.STORE_CUSTOMER,
|
|
subject="Customer Support",
|
|
initiator_type=ParticipantType.STORE,
|
|
initiator_id=test_store_user.id,
|
|
recipient_type=ParticipantType.CUSTOMER,
|
|
recipient_id=test_customer.id,
|
|
store_id=test_store.id,
|
|
)
|
|
db.commit()
|
|
|
|
assert conversation.id is not None
|
|
assert conversation.conversation_type == ConversationType.STORE_CUSTOMER
|
|
assert len(conversation.participants) == 2
|
|
|
|
# Verify participants
|
|
participant_types = [p.participant_type for p in conversation.participants]
|
|
assert ParticipantType.STORE in participant_types
|
|
assert ParticipantType.CUSTOMER in participant_types
|
|
|
|
def test_create_conversation_admin_customer(
|
|
self, db, messaging_service, test_admin, test_customer, test_store
|
|
):
|
|
"""Test creating an admin-customer conversation."""
|
|
conversation = messaging_service.create_conversation(
|
|
db=db,
|
|
conversation_type=ConversationType.ADMIN_CUSTOMER,
|
|
subject="Platform Support",
|
|
initiator_type=ParticipantType.ADMIN,
|
|
initiator_id=test_admin.id,
|
|
recipient_type=ParticipantType.CUSTOMER,
|
|
recipient_id=test_customer.id,
|
|
store_id=test_store.id,
|
|
)
|
|
db.commit()
|
|
|
|
assert conversation.conversation_type == ConversationType.ADMIN_CUSTOMER
|
|
assert len(conversation.participants) == 2
|
|
|
|
def test_create_conversation_with_initial_message(
|
|
self, db, messaging_service, test_admin, test_store_user, test_store
|
|
):
|
|
"""Test creating a conversation with an initial message."""
|
|
conversation = messaging_service.create_conversation(
|
|
db=db,
|
|
conversation_type=ConversationType.ADMIN_STORE,
|
|
subject="With Message",
|
|
initiator_type=ParticipantType.ADMIN,
|
|
initiator_id=test_admin.id,
|
|
recipient_type=ParticipantType.STORE,
|
|
recipient_id=test_store_user.id,
|
|
store_id=test_store.id,
|
|
initial_message="Hello, this is the first message!",
|
|
)
|
|
db.commit()
|
|
db.refresh(conversation)
|
|
|
|
assert conversation.message_count == 1
|
|
assert len(conversation.messages) == 1
|
|
assert conversation.messages[0].content == "Hello, this is the first message!"
|
|
|
|
def test_create_store_customer_without_store_id_fails(
|
|
self, db, messaging_service, test_store_user, test_customer
|
|
):
|
|
"""Test that store_customer conversation requires store_id."""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
messaging_service.create_conversation(
|
|
db=db,
|
|
conversation_type=ConversationType.STORE_CUSTOMER,
|
|
subject="No Store",
|
|
initiator_type=ParticipantType.STORE,
|
|
initiator_id=test_store_user.id,
|
|
recipient_type=ParticipantType.CUSTOMER,
|
|
recipient_id=test_customer.id,
|
|
store_id=None,
|
|
)
|
|
assert "store_id required" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessagingServiceGetConversation:
|
|
"""Test conversation retrieval."""
|
|
|
|
def test_get_conversation_success(
|
|
self, db, messaging_service, test_conversation_admin_store, test_admin
|
|
):
|
|
"""Test getting a conversation by ID."""
|
|
conversation = messaging_service.get_conversation(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
)
|
|
|
|
assert conversation is not None
|
|
assert conversation.id == test_conversation_admin_store.id
|
|
assert conversation.subject == "Test Admin-Store Conversation"
|
|
|
|
def test_get_conversation_not_found(self, db, messaging_service, test_admin):
|
|
"""Test getting a non-existent conversation."""
|
|
conversation = messaging_service.get_conversation(
|
|
db=db,
|
|
conversation_id=99999,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
)
|
|
|
|
assert conversation is None
|
|
|
|
def test_get_conversation_unauthorized(
|
|
self, db, messaging_service, test_conversation_admin_store, test_customer
|
|
):
|
|
"""Test getting a conversation without access."""
|
|
# Customer is not a participant in admin-store conversation
|
|
conversation = messaging_service.get_conversation(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
participant_type=ParticipantType.CUSTOMER,
|
|
participant_id=test_customer.id,
|
|
)
|
|
|
|
assert conversation is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessagingServiceListConversations:
|
|
"""Test conversation listing."""
|
|
|
|
def test_list_conversations_success(
|
|
self, db, messaging_service, multiple_conversations, test_admin
|
|
):
|
|
"""Test listing conversations for a participant."""
|
|
conversations, total, total_unread = messaging_service.list_conversations(
|
|
db=db,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
)
|
|
|
|
# Admin should see all admin-store conversations (3 of them)
|
|
assert total == 3
|
|
assert len(conversations) == 3
|
|
|
|
def test_list_conversations_with_type_filter(
|
|
self, db, messaging_service, multiple_conversations, test_store_user, test_store
|
|
):
|
|
"""Test filtering conversations by type."""
|
|
# Store should see admin-store (3) + store-customer (2) = 5
|
|
# Filter to store-customer only
|
|
conversations, total, _ = messaging_service.list_conversations(
|
|
db=db,
|
|
participant_type=ParticipantType.STORE,
|
|
participant_id=test_store_user.id,
|
|
store_id=test_store.id,
|
|
conversation_type=ConversationType.STORE_CUSTOMER,
|
|
)
|
|
|
|
assert total == 2
|
|
for conv in conversations:
|
|
assert conv.conversation_type == ConversationType.STORE_CUSTOMER
|
|
|
|
def test_list_conversations_pagination(
|
|
self, db, messaging_service, multiple_conversations, test_admin
|
|
):
|
|
"""Test pagination of conversations."""
|
|
# First page
|
|
conversations, total, _ = messaging_service.list_conversations(
|
|
db=db,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
skip=0,
|
|
limit=2,
|
|
)
|
|
|
|
assert total == 3
|
|
assert len(conversations) == 2
|
|
|
|
# Second page
|
|
conversations, total, _ = messaging_service.list_conversations(
|
|
db=db,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
skip=2,
|
|
limit=2,
|
|
)
|
|
|
|
assert total == 3
|
|
assert len(conversations) == 1
|
|
|
|
def test_list_conversations_with_closed_filter(
|
|
self, db, messaging_service, test_conversation_admin_store, closed_conversation, test_admin
|
|
):
|
|
"""Test filtering by open/closed status."""
|
|
# Only open
|
|
conversations, total, _ = messaging_service.list_conversations(
|
|
db=db,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
is_closed=False,
|
|
)
|
|
assert total == 1
|
|
assert all(not conv.is_closed for conv in conversations)
|
|
|
|
# Only closed
|
|
conversations, total, _ = messaging_service.list_conversations(
|
|
db=db,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
is_closed=True,
|
|
)
|
|
assert total == 1
|
|
assert all(conv.is_closed for conv in conversations)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessagingServiceSendMessage:
|
|
"""Test message sending."""
|
|
|
|
def test_send_message_success(
|
|
self, db, messaging_service, test_conversation_admin_store, test_admin
|
|
):
|
|
"""Test sending a message."""
|
|
message = messaging_service.send_message(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
sender_type=ParticipantType.ADMIN,
|
|
sender_id=test_admin.id,
|
|
content="Hello, this is a test message!",
|
|
)
|
|
db.commit()
|
|
|
|
assert message.id is not None
|
|
assert message.content == "Hello, this is a test message!"
|
|
assert message.sender_type == ParticipantType.ADMIN
|
|
assert message.sender_id == test_admin.id
|
|
assert message.conversation_id == test_conversation_admin_store.id
|
|
|
|
# Verify conversation was updated
|
|
db.refresh(test_conversation_admin_store)
|
|
assert test_conversation_admin_store.message_count == 1
|
|
assert test_conversation_admin_store.last_message_at is not None
|
|
|
|
def test_send_message_with_attachments(
|
|
self, db, messaging_service, test_conversation_admin_store, test_admin
|
|
):
|
|
"""Test sending a message with attachments."""
|
|
attachments = [
|
|
{
|
|
"filename": "doc1.pdf",
|
|
"original_filename": "document.pdf",
|
|
"file_path": "/uploads/messages/2025/01/1/doc1.pdf",
|
|
"file_size": 12345,
|
|
"mime_type": "application/pdf",
|
|
"is_image": False,
|
|
}
|
|
]
|
|
|
|
message = messaging_service.send_message(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
sender_type=ParticipantType.ADMIN,
|
|
sender_id=test_admin.id,
|
|
content="See attached document.",
|
|
attachments=attachments,
|
|
)
|
|
db.commit()
|
|
db.refresh(message)
|
|
|
|
assert len(message.attachments) == 1
|
|
assert message.attachments[0].original_filename == "document.pdf"
|
|
|
|
def test_send_message_updates_unread_count(
|
|
self, db, messaging_service, test_conversation_admin_store, test_admin, test_store_user
|
|
):
|
|
"""Test that sending a message updates unread count for other participants."""
|
|
# Send message as admin
|
|
messaging_service.send_message(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
sender_type=ParticipantType.ADMIN,
|
|
sender_id=test_admin.id,
|
|
content="Test message",
|
|
)
|
|
db.commit()
|
|
|
|
# Check that store user has unread count increased
|
|
store_participant = (
|
|
db.query(ConversationParticipant)
|
|
.filter(
|
|
ConversationParticipant.conversation_id == test_conversation_admin_store.id,
|
|
ConversationParticipant.participant_type == ParticipantType.STORE,
|
|
ConversationParticipant.participant_id == test_store_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
assert store_participant.unread_count == 1
|
|
|
|
# Admin's unread count should be 0
|
|
admin_participant = (
|
|
db.query(ConversationParticipant)
|
|
.filter(
|
|
ConversationParticipant.conversation_id == test_conversation_admin_store.id,
|
|
ConversationParticipant.participant_type == ParticipantType.ADMIN,
|
|
ConversationParticipant.participant_id == test_admin.id,
|
|
)
|
|
.first()
|
|
)
|
|
assert admin_participant.unread_count == 0
|
|
|
|
def test_send_system_message(
|
|
self, db, messaging_service, test_conversation_admin_store, test_admin
|
|
):
|
|
"""Test sending a system message."""
|
|
message = messaging_service.send_message(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
sender_type=ParticipantType.ADMIN,
|
|
sender_id=test_admin.id,
|
|
content="Conversation closed",
|
|
is_system_message=True,
|
|
)
|
|
db.commit()
|
|
|
|
assert message.is_system_message is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessagingServiceMarkRead:
|
|
"""Test marking conversations as read."""
|
|
|
|
def test_mark_conversation_read(
|
|
self, db, messaging_service, test_conversation_admin_store, test_admin, test_store_user
|
|
):
|
|
"""Test marking a conversation as read."""
|
|
# Send a message to create unread count
|
|
messaging_service.send_message(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
sender_type=ParticipantType.ADMIN,
|
|
sender_id=test_admin.id,
|
|
content="Test message",
|
|
)
|
|
db.commit()
|
|
|
|
# Mark as read for store
|
|
result = messaging_service.mark_conversation_read(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
reader_type=ParticipantType.STORE,
|
|
reader_id=test_store_user.id,
|
|
)
|
|
db.commit()
|
|
|
|
assert result is True
|
|
|
|
# Verify unread count is reset
|
|
store_participant = (
|
|
db.query(ConversationParticipant)
|
|
.filter(
|
|
ConversationParticipant.conversation_id == test_conversation_admin_store.id,
|
|
ConversationParticipant.participant_type == ParticipantType.STORE,
|
|
)
|
|
.first()
|
|
)
|
|
assert store_participant.unread_count == 0
|
|
assert store_participant.last_read_at is not None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessagingServiceUnreadCount:
|
|
"""Test unread count retrieval."""
|
|
|
|
def test_get_unread_count(
|
|
self, db, messaging_service, multiple_conversations, test_admin, test_store_user
|
|
):
|
|
"""Test getting total unread count for a participant."""
|
|
# Send messages in multiple conversations (first 2 are admin-store)
|
|
for conv in multiple_conversations[:2]:
|
|
messaging_service.send_message(
|
|
db=db,
|
|
conversation_id=conv.id,
|
|
sender_type=ParticipantType.STORE,
|
|
sender_id=test_store_user.id,
|
|
content="Test message",
|
|
)
|
|
db.commit()
|
|
|
|
# Admin should have 2 unread messages
|
|
unread_count = messaging_service.get_unread_count(
|
|
db=db,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
)
|
|
assert unread_count == 2
|
|
|
|
def test_get_unread_count_zero(self, db, messaging_service, test_admin):
|
|
"""Test unread count when no messages."""
|
|
unread_count = messaging_service.get_unread_count(
|
|
db=db,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
)
|
|
assert unread_count == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessagingServiceCloseReopen:
|
|
"""Test conversation close/reopen."""
|
|
|
|
def test_close_conversation(
|
|
self, db, messaging_service, test_conversation_admin_store, test_admin
|
|
):
|
|
"""Test closing a conversation."""
|
|
conversation = messaging_service.close_conversation(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
closer_type=ParticipantType.ADMIN,
|
|
closer_id=test_admin.id,
|
|
)
|
|
db.commit()
|
|
|
|
assert conversation is not None
|
|
assert conversation.is_closed is True
|
|
assert conversation.closed_at is not None
|
|
assert conversation.closed_by_type == ParticipantType.ADMIN
|
|
assert conversation.closed_by_id == test_admin.id
|
|
|
|
# Should have system message
|
|
db.refresh(conversation)
|
|
assert any(m.is_system_message and "closed" in m.content for m in conversation.messages)
|
|
|
|
def test_reopen_conversation(
|
|
self, db, messaging_service, closed_conversation, test_admin
|
|
):
|
|
"""Test reopening a closed conversation."""
|
|
conversation = messaging_service.reopen_conversation(
|
|
db=db,
|
|
conversation_id=closed_conversation.id,
|
|
opener_type=ParticipantType.ADMIN,
|
|
opener_id=test_admin.id,
|
|
)
|
|
db.commit()
|
|
|
|
assert conversation is not None
|
|
assert conversation.is_closed is False
|
|
assert conversation.closed_at is None
|
|
assert conversation.closed_by_type is None
|
|
assert conversation.closed_by_id is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessagingServiceParticipantInfo:
|
|
"""Test participant info retrieval."""
|
|
|
|
def test_get_participant_info_admin(self, db, messaging_service, test_admin):
|
|
"""Test getting admin participant info."""
|
|
info = messaging_service.get_participant_info(
|
|
db=db,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
)
|
|
|
|
assert info is not None
|
|
assert info["id"] == test_admin.id
|
|
assert info["type"] == "admin"
|
|
assert "email" in info
|
|
|
|
def test_get_participant_info_customer(self, db, messaging_service, test_customer):
|
|
"""Test getting customer participant info."""
|
|
info = messaging_service.get_participant_info(
|
|
db=db,
|
|
participant_type=ParticipantType.CUSTOMER,
|
|
participant_id=test_customer.id,
|
|
)
|
|
|
|
assert info is not None
|
|
assert info["id"] == test_customer.id
|
|
assert info["type"] == "customer"
|
|
assert info["name"] == "John Doe"
|
|
|
|
def test_get_participant_info_not_found(self, db, messaging_service):
|
|
"""Test getting info for non-existent participant."""
|
|
info = messaging_service.get_participant_info(
|
|
db=db,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=99999,
|
|
)
|
|
|
|
assert info is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessagingServiceNotificationPreferences:
|
|
"""Test notification preference updates."""
|
|
|
|
def test_update_notification_preferences(
|
|
self, db, messaging_service, test_conversation_admin_store, test_admin
|
|
):
|
|
"""Test updating notification preferences."""
|
|
result = messaging_service.update_notification_preferences(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
email_notifications=False,
|
|
muted=True,
|
|
)
|
|
db.commit()
|
|
|
|
assert result is True
|
|
|
|
# Verify preferences updated
|
|
participant = (
|
|
db.query(ConversationParticipant)
|
|
.filter(
|
|
ConversationParticipant.conversation_id == test_conversation_admin_store.id,
|
|
ConversationParticipant.participant_type == ParticipantType.ADMIN,
|
|
)
|
|
.first()
|
|
)
|
|
assert participant.email_notifications is False
|
|
assert participant.muted is True
|
|
|
|
def test_update_notification_preferences_no_changes(
|
|
self, db, messaging_service, test_conversation_admin_store, test_admin
|
|
):
|
|
"""Test updating with no changes."""
|
|
result = messaging_service.update_notification_preferences(
|
|
db=db,
|
|
conversation_id=test_conversation_admin_store.id,
|
|
participant_type=ParticipantType.ADMIN,
|
|
participant_id=test_admin.id,
|
|
)
|
|
|
|
assert result is False
|