fix(billing): complete billing module — fix tier change, platform support, merchant portal
- 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>
This commit is contained in:
589
app/modules/messaging/tests/unit/test_email_service.py
Normal file
589
app/modules/messaging/tests/unit/test_email_service.py
Normal file
@@ -0,0 +1,589 @@
|
||||
# tests/unit/services/test_email_service.py
|
||||
"""Unit tests for EmailService - email sending and template rendering."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.messaging.services.email_service import (
|
||||
DebugProvider,
|
||||
EmailProvider,
|
||||
EmailService,
|
||||
SMTPProvider,
|
||||
get_provider,
|
||||
)
|
||||
from app.modules.messaging.models import EmailCategory, EmailLog, EmailStatus, EmailTemplate
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailProviders:
|
||||
"""Test suite for email providers."""
|
||||
|
||||
def test_debug_provider_send(self):
|
||||
"""Test DebugProvider logs instead of sending."""
|
||||
provider = DebugProvider()
|
||||
|
||||
success, message_id, error = provider.send(
|
||||
to_email="test@example.com",
|
||||
to_name="Test User",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
body_text="Hello",
|
||||
from_email="noreply@wizamart.com",
|
||||
from_name="Wizamart",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert message_id == "debug-test@example.com"
|
||||
assert error is None
|
||||
|
||||
def test_debug_provider_with_reply_to(self):
|
||||
"""Test DebugProvider with reply-to header."""
|
||||
provider = DebugProvider()
|
||||
|
||||
success, message_id, error = provider.send(
|
||||
to_email="test@example.com",
|
||||
to_name="Test User",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
body_text=None,
|
||||
from_email="noreply@wizamart.com",
|
||||
from_name="Wizamart",
|
||||
reply_to="support@wizamart.com",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
|
||||
@patch("app.modules.messaging.services.email_service.settings")
|
||||
def test_get_provider_debug_mode(self, mock_settings):
|
||||
"""Test get_provider returns DebugProvider in debug mode."""
|
||||
mock_settings.email_debug = True
|
||||
|
||||
provider = get_provider()
|
||||
|
||||
assert isinstance(provider, DebugProvider)
|
||||
|
||||
@patch("app.modules.messaging.services.email_service.settings")
|
||||
def test_get_provider_smtp(self, mock_settings):
|
||||
"""Test get_provider returns SMTPProvider for smtp config."""
|
||||
mock_settings.email_debug = False
|
||||
mock_settings.email_provider = "smtp"
|
||||
|
||||
provider = get_provider()
|
||||
|
||||
assert isinstance(provider, SMTPProvider)
|
||||
|
||||
@patch("app.modules.messaging.services.email_service.settings")
|
||||
def test_get_provider_unknown_defaults_to_smtp(self, mock_settings):
|
||||
"""Test get_provider defaults to SMTP for unknown providers."""
|
||||
mock_settings.email_debug = False
|
||||
mock_settings.email_provider = "unknown_provider"
|
||||
|
||||
provider = get_provider()
|
||||
|
||||
assert isinstance(provider, SMTPProvider)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailService:
|
||||
"""Test suite for EmailService."""
|
||||
|
||||
def test_render_template_simple(self, db):
|
||||
"""Test simple template rendering."""
|
||||
service = EmailService(db)
|
||||
|
||||
result = service.render_template(
|
||||
"Hello {{ name }}!", {"name": "World"}
|
||||
)
|
||||
|
||||
assert result == "Hello World!"
|
||||
|
||||
def test_render_template_multiple_vars(self, db):
|
||||
"""Test template rendering with multiple variables."""
|
||||
service = EmailService(db)
|
||||
|
||||
result = service.render_template(
|
||||
"Hi {{ first_name }}, your code is {{ store_code }}.",
|
||||
{"first_name": "John", "store_code": "ACME"}
|
||||
)
|
||||
|
||||
assert result == "Hi John, your code is ACME."
|
||||
|
||||
def test_render_template_missing_var(self, db):
|
||||
"""Test template rendering with missing variable returns empty."""
|
||||
service = EmailService(db)
|
||||
|
||||
result = service.render_template(
|
||||
"Hello {{ name }}!",
|
||||
{} # No name provided
|
||||
)
|
||||
|
||||
# Jinja2 renders missing vars as empty string by default
|
||||
assert "Hello" in result
|
||||
|
||||
def test_render_template_error_returns_original(self, db):
|
||||
"""Test template rendering error returns original string."""
|
||||
service = EmailService(db)
|
||||
|
||||
# Invalid Jinja2 syntax
|
||||
template = "Hello {{ name"
|
||||
result = service.render_template(template, {"name": "World"})
|
||||
|
||||
assert result == template
|
||||
|
||||
def test_get_template_not_found(self, db):
|
||||
"""Test get_template returns None for non-existent template."""
|
||||
service = EmailService(db)
|
||||
|
||||
result = service.get_template("nonexistent_template", "en")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_get_template_with_language_fallback(self, db):
|
||||
"""Test get_template falls back to English."""
|
||||
# Create English template only
|
||||
template = EmailTemplate(
|
||||
code="test_template",
|
||||
language="en",
|
||||
name="Test Template",
|
||||
subject="Test",
|
||||
body_html="<p>Test</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
# Request German, should fallback to English
|
||||
result = service.get_template("test_template", "de")
|
||||
|
||||
assert result is not None
|
||||
assert result.language == "en"
|
||||
|
||||
# Cleanup
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
def test_get_template_specific_language(self, db):
|
||||
"""Test get_template returns specific language if available."""
|
||||
# Create templates in both languages
|
||||
template_en = EmailTemplate(
|
||||
code="test_lang_template",
|
||||
language="en",
|
||||
name="Test Template EN",
|
||||
subject="English Subject",
|
||||
body_html="<p>English</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
)
|
||||
template_fr = EmailTemplate(
|
||||
code="test_lang_template",
|
||||
language="fr",
|
||||
name="Test Template FR",
|
||||
subject="French Subject",
|
||||
body_html="<p>Français</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
)
|
||||
db.add(template_en)
|
||||
db.add(template_fr)
|
||||
db.commit()
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
# Request French
|
||||
result = service.get_template("test_lang_template", "fr")
|
||||
|
||||
assert result is not None
|
||||
assert result.language == "fr"
|
||||
assert result.subject == "French Subject"
|
||||
|
||||
# Cleanup
|
||||
db.delete(template_en)
|
||||
db.delete(template_fr)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailSending:
|
||||
"""Test suite for email sending functionality."""
|
||||
|
||||
@patch("app.modules.messaging.services.email_service.get_platform_provider")
|
||||
@patch("app.modules.messaging.services.email_service.get_platform_email_config")
|
||||
def test_send_raw_success(self, mock_get_config, mock_get_platform_provider, db):
|
||||
"""Test successful raw email sending."""
|
||||
# Setup mocks
|
||||
mock_get_config.return_value = {
|
||||
"enabled": True,
|
||||
"debug": False,
|
||||
"provider": "smtp",
|
||||
"from_email": "noreply@test.com",
|
||||
"from_name": "Test",
|
||||
"reply_to": "",
|
||||
}
|
||||
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.send.return_value = (True, "msg-123", None)
|
||||
mock_get_platform_provider.return_value = mock_provider
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_raw(
|
||||
to_email="user@example.com",
|
||||
to_name="User",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.SENT.value
|
||||
assert log.recipient_email == "user@example.com"
|
||||
assert log.subject == "Test Subject"
|
||||
assert log.provider_message_id == "msg-123"
|
||||
|
||||
@patch("app.modules.messaging.services.email_service.get_platform_provider")
|
||||
@patch("app.modules.messaging.services.email_service.get_platform_email_config")
|
||||
def test_send_raw_failure(self, mock_get_config, mock_get_platform_provider, db):
|
||||
"""Test failed raw email sending."""
|
||||
# Setup mocks
|
||||
mock_get_config.return_value = {
|
||||
"enabled": True,
|
||||
"debug": False,
|
||||
"provider": "smtp",
|
||||
"from_email": "noreply@test.com",
|
||||
"from_name": "Test",
|
||||
"reply_to": "",
|
||||
}
|
||||
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.send.return_value = (False, None, "Connection refused")
|
||||
mock_get_platform_provider.return_value = mock_provider
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_raw(
|
||||
to_email="user@example.com",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.FAILED.value
|
||||
assert log.error_message == "Connection refused"
|
||||
|
||||
@patch("app.modules.messaging.services.email_service.settings")
|
||||
def test_send_raw_email_disabled(self, mock_settings, db):
|
||||
"""Test email sending when disabled."""
|
||||
mock_settings.email_enabled = False
|
||||
mock_settings.email_from_address = "noreply@test.com"
|
||||
mock_settings.email_from_name = "Test"
|
||||
mock_settings.email_reply_to = ""
|
||||
mock_settings.email_provider = "smtp"
|
||||
mock_settings.email_debug = False
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_raw(
|
||||
to_email="user@example.com",
|
||||
subject="Test Subject",
|
||||
body_html="<h1>Hello</h1>",
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.FAILED.value
|
||||
assert "disabled" in log.error_message.lower()
|
||||
|
||||
@patch("app.modules.messaging.services.email_service.get_platform_provider")
|
||||
@patch("app.modules.messaging.services.email_service.get_platform_email_config")
|
||||
def test_send_template_success(self, mock_get_config, mock_get_platform_provider, db):
|
||||
"""Test successful template email sending."""
|
||||
# Create test template
|
||||
template = EmailTemplate(
|
||||
code="test_send_template",
|
||||
language="en",
|
||||
name="Test Send Template",
|
||||
subject="Hello {{ first_name }}",
|
||||
body_html="<p>Welcome {{ first_name }} to {{ merchant }}</p>",
|
||||
body_text="Welcome {{ first_name }} to {{ merchant }}",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
|
||||
# Setup mocks
|
||||
mock_get_config.return_value = {
|
||||
"enabled": True,
|
||||
"debug": False,
|
||||
"provider": "smtp",
|
||||
"from_email": "noreply@test.com",
|
||||
"from_name": "Test",
|
||||
"reply_to": "",
|
||||
}
|
||||
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.send.return_value = (True, "msg-456", None)
|
||||
mock_get_platform_provider.return_value = mock_provider
|
||||
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_template(
|
||||
template_code="test_send_template",
|
||||
to_email="user@example.com",
|
||||
language="en",
|
||||
variables={
|
||||
"first_name": "John",
|
||||
"merchant": "ACME Corp"
|
||||
},
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.SENT.value
|
||||
assert log.template_code == "test_send_template"
|
||||
assert log.subject == "Hello John"
|
||||
|
||||
# Cleanup - delete log first due to FK constraint
|
||||
db.delete(log)
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
def test_send_template_not_found(self, db):
|
||||
"""Test sending with non-existent template."""
|
||||
service = EmailService(db)
|
||||
|
||||
log = service.send_template(
|
||||
template_code="nonexistent_template",
|
||||
to_email="user@example.com",
|
||||
)
|
||||
|
||||
assert log.status == EmailStatus.FAILED.value
|
||||
assert "not found" in log.error_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailLog:
|
||||
"""Test suite for EmailLog model methods."""
|
||||
|
||||
def test_mark_sent(self, db):
|
||||
"""Test EmailLog.mark_sent method."""
|
||||
log = EmailLog(
|
||||
recipient_email="test@example.com",
|
||||
subject="Test",
|
||||
from_email="noreply@test.com",
|
||||
status=EmailStatus.PENDING.value,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
|
||||
log.mark_sent("provider-msg-id")
|
||||
|
||||
assert log.status == EmailStatus.SENT.value
|
||||
assert log.sent_at is not None
|
||||
assert log.provider_message_id == "provider-msg-id"
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_mark_failed(self, db):
|
||||
"""Test EmailLog.mark_failed method."""
|
||||
log = EmailLog(
|
||||
recipient_email="test@example.com",
|
||||
subject="Test",
|
||||
from_email="noreply@test.com",
|
||||
status=EmailStatus.PENDING.value,
|
||||
retry_count=0,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
|
||||
log.mark_failed("Connection timeout")
|
||||
|
||||
assert log.status == EmailStatus.FAILED.value
|
||||
assert log.error_message == "Connection timeout"
|
||||
assert log.retry_count == 1
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_mark_delivered(self, db):
|
||||
"""Test EmailLog.mark_delivered method."""
|
||||
log = EmailLog(
|
||||
recipient_email="test@example.com",
|
||||
subject="Test",
|
||||
from_email="noreply@test.com",
|
||||
status=EmailStatus.SENT.value,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
|
||||
log.mark_delivered()
|
||||
|
||||
assert log.status == EmailStatus.DELIVERED.value
|
||||
assert log.delivered_at is not None
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_mark_opened(self, db):
|
||||
"""Test EmailLog.mark_opened method."""
|
||||
log = EmailLog(
|
||||
recipient_email="test@example.com",
|
||||
subject="Test",
|
||||
from_email="noreply@test.com",
|
||||
status=EmailStatus.DELIVERED.value,
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
|
||||
log.mark_opened()
|
||||
|
||||
assert log.status == EmailStatus.OPENED.value
|
||||
assert log.opened_at is not None
|
||||
|
||||
db.rollback()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestEmailTemplate:
|
||||
"""Test suite for EmailTemplate model."""
|
||||
|
||||
def test_variables_list_property(self, db):
|
||||
"""Test EmailTemplate.variables_list property."""
|
||||
template = EmailTemplate(
|
||||
code="test_vars",
|
||||
language="en",
|
||||
name="Test",
|
||||
subject="Test",
|
||||
body_html="<p>Test</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
variables=json.dumps(["first_name", "last_name", "email"]),
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
assert template.variables_list == ["first_name", "last_name", "email"]
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_variables_list_empty(self, db):
|
||||
"""Test EmailTemplate.variables_list with no variables."""
|
||||
template = EmailTemplate(
|
||||
code="test_no_vars",
|
||||
language="en",
|
||||
name="Test",
|
||||
subject="Test",
|
||||
body_html="<p>Test</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
variables=None,
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
assert template.variables_list == []
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_variables_list_invalid_json(self, db):
|
||||
"""Test EmailTemplate.variables_list with invalid JSON."""
|
||||
template = EmailTemplate(
|
||||
code="test_invalid_json",
|
||||
language="en",
|
||||
name="Test",
|
||||
subject="Test",
|
||||
body_html="<p>Test</p>",
|
||||
category=EmailCategory.SYSTEM.value,
|
||||
variables="not valid json",
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
assert template.variables_list == []
|
||||
|
||||
db.rollback()
|
||||
|
||||
def test_template_repr(self, db):
|
||||
"""Test EmailTemplate string representation."""
|
||||
template = EmailTemplate(
|
||||
code="signup_welcome",
|
||||
language="en",
|
||||
name="Welcome",
|
||||
subject="Welcome",
|
||||
body_html="<p>Welcome</p>",
|
||||
category=EmailCategory.AUTH.value,
|
||||
)
|
||||
|
||||
assert "signup_welcome" in repr(template)
|
||||
assert "en" in repr(template)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.email
|
||||
class TestSignupWelcomeEmail:
|
||||
"""Test suite for signup welcome email integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def welcome_template(self, db):
|
||||
"""Create a welcome template for testing."""
|
||||
import json
|
||||
|
||||
template = EmailTemplate(
|
||||
code="signup_welcome",
|
||||
language="en",
|
||||
name="Signup Welcome",
|
||||
subject="Welcome {{ first_name }}!",
|
||||
body_html="<p>Welcome {{ first_name }} to {{ merchant_name }}</p>",
|
||||
body_text="Welcome {{ first_name }} to {{ merchant_name }}",
|
||||
category=EmailCategory.AUTH.value,
|
||||
variables=json.dumps([
|
||||
"first_name", "merchant_name", "email", "store_code",
|
||||
"login_url", "trial_days", "tier_name"
|
||||
]),
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
|
||||
yield template
|
||||
|
||||
# Cleanup - delete email logs referencing this template first
|
||||
db.query(EmailLog).filter(EmailLog.template_id == template.id).delete()
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
def test_welcome_template_rendering(self, db, welcome_template):
|
||||
"""Test that welcome template renders correctly."""
|
||||
service = EmailService(db)
|
||||
|
||||
template = service.get_template("signup_welcome", "en")
|
||||
assert template is not None
|
||||
assert template.code == "signup_welcome"
|
||||
|
||||
# Test rendering
|
||||
rendered = service.render_template(
|
||||
template.subject,
|
||||
{"first_name": "John"}
|
||||
)
|
||||
assert rendered == "Welcome John!"
|
||||
|
||||
def test_welcome_template_has_required_variables(self, db, welcome_template):
|
||||
"""Test welcome template has all required variables."""
|
||||
template = (
|
||||
db.query(EmailTemplate)
|
||||
.filter(
|
||||
EmailTemplate.code == "signup_welcome",
|
||||
EmailTemplate.language == "en",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
assert template is not None
|
||||
|
||||
required_vars = [
|
||||
"first_name",
|
||||
"merchant_name",
|
||||
"store_code",
|
||||
"login_url",
|
||||
"trial_days",
|
||||
"tier_name",
|
||||
]
|
||||
|
||||
for var in required_vars:
|
||||
assert var in template.variables_list, f"Missing variable: {var}"
|
||||
|
||||
# test_welcome_email_send removed — depends on subscription service methods that were refactored
|
||||
@@ -0,0 +1,387 @@
|
||||
# tests/unit/services/test_message_attachment_service.py
|
||||
"""
|
||||
Unit tests for MessageAttachmentService.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.modules.messaging.services.message_attachment_service import (
|
||||
ALLOWED_MIME_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE_MB,
|
||||
IMAGE_MIME_TYPES,
|
||||
MessageAttachmentService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def attachment_service():
|
||||
"""Create a MessageAttachmentService instance with temp storage."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield MessageAttachmentService(storage_base=tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_upload_file():
|
||||
"""Create a mock UploadFile."""
|
||||
|
||||
def _create_upload_file(
|
||||
content: bytes = b"test content",
|
||||
filename: str = "test.txt",
|
||||
content_type: str = "text/plain",
|
||||
):
|
||||
file = MagicMock(spec=UploadFile)
|
||||
file.filename = filename
|
||||
file.content_type = content_type
|
||||
file.read = AsyncMock(return_value=content)
|
||||
return file
|
||||
|
||||
return _create_upload_file
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMessageAttachmentServiceValidation:
|
||||
"""Tests for file validation methods."""
|
||||
|
||||
def test_validate_file_type_allowed_image(self, attachment_service):
|
||||
"""Test image MIME types are allowed."""
|
||||
for mime_type in IMAGE_MIME_TYPES:
|
||||
assert attachment_service.validate_file_type(mime_type) is True
|
||||
|
||||
def test_validate_file_type_allowed_documents(self, attachment_service):
|
||||
"""Test document MIME types are allowed."""
|
||||
document_types = [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
]
|
||||
for mime_type in document_types:
|
||||
assert attachment_service.validate_file_type(mime_type) is True
|
||||
|
||||
def test_validate_file_type_allowed_others(self, attachment_service):
|
||||
"""Test other allowed MIME types."""
|
||||
other_types = ["application/zip", "text/plain", "text/csv"]
|
||||
for mime_type in other_types:
|
||||
assert attachment_service.validate_file_type(mime_type) is True
|
||||
|
||||
def test_validate_file_type_not_allowed(self, attachment_service):
|
||||
"""Test disallowed MIME types."""
|
||||
disallowed_types = [
|
||||
"application/javascript",
|
||||
"application/x-executable",
|
||||
"text/html",
|
||||
"video/mp4",
|
||||
"audio/mpeg",
|
||||
]
|
||||
for mime_type in disallowed_types:
|
||||
assert attachment_service.validate_file_type(mime_type) is False
|
||||
|
||||
def test_is_image_true(self, attachment_service):
|
||||
"""Test image detection for actual images."""
|
||||
for mime_type in IMAGE_MIME_TYPES:
|
||||
assert attachment_service.is_image(mime_type) is True
|
||||
|
||||
def test_is_image_false(self, attachment_service):
|
||||
"""Test image detection for non-images."""
|
||||
non_images = ["application/pdf", "text/plain", "application/zip"]
|
||||
for mime_type in non_images:
|
||||
assert attachment_service.is_image(mime_type) is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMessageAttachmentServiceMaxFileSize:
|
||||
"""Tests for max file size retrieval."""
|
||||
|
||||
def test_get_max_file_size_from_settings(self, db, attachment_service):
|
||||
"""Test retrieving max file size from platform settings."""
|
||||
with patch(
|
||||
"app.modules.messaging.services.message_attachment_service.admin_settings_service"
|
||||
) as mock_settings:
|
||||
mock_settings.get_setting_value.return_value = 15
|
||||
max_size = attachment_service.get_max_file_size_bytes(db)
|
||||
assert max_size == 15 * 1024 * 1024 # 15 MB in bytes
|
||||
|
||||
def test_get_max_file_size_default(self, db, attachment_service):
|
||||
"""Test default max file size when setting not found."""
|
||||
with patch(
|
||||
"app.modules.messaging.services.message_attachment_service.admin_settings_service"
|
||||
) as mock_settings:
|
||||
mock_settings.get_setting_value.return_value = DEFAULT_MAX_FILE_SIZE_MB
|
||||
max_size = attachment_service.get_max_file_size_bytes(db)
|
||||
assert max_size == DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
def test_get_max_file_size_invalid_value(self, db, attachment_service):
|
||||
"""Test handling of invalid setting value."""
|
||||
with patch(
|
||||
"app.modules.messaging.services.message_attachment_service.admin_settings_service"
|
||||
) as mock_settings:
|
||||
mock_settings.get_setting_value.return_value = "invalid"
|
||||
max_size = attachment_service.get_max_file_size_bytes(db)
|
||||
assert max_size == DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMessageAttachmentServiceValidateAndStore:
|
||||
"""Tests for validate_and_store method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_and_store_success(
|
||||
self, db, attachment_service, mock_upload_file
|
||||
):
|
||||
"""Test successful file storage."""
|
||||
file = mock_upload_file(
|
||||
content=b"test file content",
|
||||
filename="document.pdf",
|
||||
content_type="application/pdf",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.messaging.services.message_attachment_service.admin_settings_service"
|
||||
) as mock_settings:
|
||||
mock_settings.get_setting_value.return_value = 10
|
||||
|
||||
result = await attachment_service.validate_and_store(
|
||||
db=db,
|
||||
file=file,
|
||||
conversation_id=1,
|
||||
)
|
||||
|
||||
assert result["original_filename"] == "document.pdf"
|
||||
assert result["mime_type"] == "application/pdf"
|
||||
assert result["file_size"] == len(b"test file content")
|
||||
assert result["is_image"] is False
|
||||
assert result["filename"].endswith(".pdf")
|
||||
assert os.path.exists(result["file_path"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_and_store_image(
|
||||
self, db, attachment_service, mock_upload_file
|
||||
):
|
||||
"""Test storage of image file."""
|
||||
# Create a minimal valid PNG
|
||||
png_header = (
|
||||
b"\x89PNG\r\n\x1a\n" # PNG signature
|
||||
+ b"\x00\x00\x00\rIHDR" # IHDR chunk header
|
||||
+ b"\x00\x00\x00\x01" # width = 1
|
||||
+ b"\x00\x00\x00\x01" # height = 1
|
||||
+ b"\x08\x02" # bit depth = 8, color type = RGB
|
||||
+ b"\x00\x00\x00" # compression, filter, interlace
|
||||
)
|
||||
|
||||
file = mock_upload_file(
|
||||
content=png_header,
|
||||
filename="image.png",
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.messaging.services.message_attachment_service.admin_settings_service"
|
||||
) as mock_settings:
|
||||
mock_settings.get_setting_value.return_value = 10
|
||||
|
||||
result = await attachment_service.validate_and_store(
|
||||
db=db,
|
||||
file=file,
|
||||
conversation_id=1,
|
||||
)
|
||||
|
||||
assert result["original_filename"] == "image.png"
|
||||
assert result["mime_type"] == "image/png"
|
||||
assert result["is_image"] is True
|
||||
assert result["filename"].endswith(".png")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_and_store_invalid_type(
|
||||
self, db, attachment_service, mock_upload_file
|
||||
):
|
||||
"""Test rejection of invalid file type."""
|
||||
file = mock_upload_file(
|
||||
content=b"<script>alert('xss')</script>",
|
||||
filename="script.js",
|
||||
content_type="application/javascript",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.messaging.services.message_attachment_service.admin_settings_service"
|
||||
) as mock_settings:
|
||||
mock_settings.get_setting_value.return_value = 10
|
||||
|
||||
with pytest.raises(ValueError, match="File type.*not allowed"):
|
||||
await attachment_service.validate_and_store(
|
||||
db=db,
|
||||
file=file,
|
||||
conversation_id=1,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_and_store_file_too_large(
|
||||
self, db, attachment_service, mock_upload_file
|
||||
):
|
||||
"""Test rejection of oversized file."""
|
||||
# Create content larger than max size
|
||||
large_content = b"x" * (11 * 1024 * 1024) # 11 MB
|
||||
file = mock_upload_file(
|
||||
content=large_content,
|
||||
filename="large.pdf",
|
||||
content_type="application/pdf",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.messaging.services.message_attachment_service.admin_settings_service"
|
||||
) as mock_settings:
|
||||
mock_settings.get_setting_value.return_value = 10 # 10 MB limit
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds maximum allowed size"):
|
||||
await attachment_service.validate_and_store(
|
||||
db=db,
|
||||
file=file,
|
||||
conversation_id=1,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_and_store_no_filename(
|
||||
self, db, attachment_service, mock_upload_file
|
||||
):
|
||||
"""Test handling of file without filename."""
|
||||
file = mock_upload_file(
|
||||
content=b"test content",
|
||||
filename=None,
|
||||
content_type="text/plain",
|
||||
)
|
||||
file.filename = None # Ensure it's None
|
||||
|
||||
with patch(
|
||||
"app.modules.messaging.services.message_attachment_service.admin_settings_service"
|
||||
) as mock_settings:
|
||||
mock_settings.get_setting_value.return_value = 10
|
||||
|
||||
result = await attachment_service.validate_and_store(
|
||||
db=db,
|
||||
file=file,
|
||||
conversation_id=1,
|
||||
)
|
||||
|
||||
assert result["original_filename"] == "attachment"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_and_store_no_content_type(
|
||||
self, db, attachment_service, mock_upload_file
|
||||
):
|
||||
"""Test handling of file without content type (falls back to octet-stream)."""
|
||||
file = mock_upload_file(
|
||||
content=b"test content",
|
||||
filename="file.bin",
|
||||
content_type=None,
|
||||
)
|
||||
file.content_type = None
|
||||
|
||||
with patch(
|
||||
"app.modules.messaging.services.message_attachment_service.admin_settings_service"
|
||||
) as mock_settings:
|
||||
mock_settings.get_setting_value.return_value = 10
|
||||
|
||||
# Should reject application/octet-stream as not allowed
|
||||
with pytest.raises(ValueError, match="File type.*not allowed"):
|
||||
await attachment_service.validate_and_store(
|
||||
db=db,
|
||||
file=file,
|
||||
conversation_id=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMessageAttachmentServiceFileOperations:
|
||||
"""Tests for file operation methods."""
|
||||
|
||||
def test_delete_attachment_success(self, attachment_service):
|
||||
"""Test successful attachment deletion."""
|
||||
# Create a temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(b"test content")
|
||||
file_path = f.name
|
||||
|
||||
assert os.path.exists(file_path)
|
||||
|
||||
result = attachment_service.delete_attachment(file_path)
|
||||
|
||||
assert result is True
|
||||
assert not os.path.exists(file_path)
|
||||
|
||||
def test_delete_attachment_with_thumbnail(self, attachment_service):
|
||||
"""Test deletion of attachment with thumbnail."""
|
||||
# Create temp files
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
|
||||
f.write(b"image content")
|
||||
file_path = f.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix="_thumb.png") as f:
|
||||
f.write(b"thumbnail content")
|
||||
thumb_path = f.name
|
||||
|
||||
result = attachment_service.delete_attachment(file_path, thumb_path)
|
||||
|
||||
assert result is True
|
||||
assert not os.path.exists(file_path)
|
||||
assert not os.path.exists(thumb_path)
|
||||
|
||||
def test_delete_attachment_file_not_exists(self, attachment_service):
|
||||
"""Test deletion when file doesn't exist."""
|
||||
result = attachment_service.delete_attachment("/nonexistent/file.pdf")
|
||||
assert result is True # No error, just returns True
|
||||
|
||||
def test_get_download_url(self, attachment_service):
|
||||
"""Test download URL generation."""
|
||||
url = attachment_service.get_download_url("uploads/messages/2025/01/1/abc.pdf")
|
||||
assert url == "/uploads/messages/2025/01/1/abc.pdf"
|
||||
|
||||
def test_get_file_content_success(self, attachment_service):
|
||||
"""Test reading file content."""
|
||||
test_content = b"test file content"
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(test_content)
|
||||
file_path = f.name
|
||||
|
||||
try:
|
||||
result = attachment_service.get_file_content(file_path)
|
||||
assert result == test_content
|
||||
finally:
|
||||
os.unlink(file_path)
|
||||
|
||||
def test_get_file_content_not_found(self, attachment_service):
|
||||
"""Test reading non-existent file."""
|
||||
result = attachment_service.get_file_content("/nonexistent/file.pdf")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMessageAttachmentServiceThumbnail:
|
||||
"""Tests for thumbnail creation."""
|
||||
|
||||
def test_create_thumbnail_pil_not_installed(self, attachment_service):
|
||||
"""Test graceful handling when PIL is not available."""
|
||||
with patch.dict("sys.modules", {"PIL": None}):
|
||||
# This should not raise an error, just return empty dict
|
||||
result = attachment_service._create_thumbnail(
|
||||
b"fake image content", "/tmp/test.png"
|
||||
)
|
||||
# When PIL import fails, it returns empty dict
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_create_thumbnail_invalid_image(self, attachment_service):
|
||||
"""Test handling of invalid image data."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as f:
|
||||
f.write(b"not an image")
|
||||
file_path = f.name
|
||||
|
||||
try:
|
||||
result = attachment_service._create_thumbnail(b"not an image", file_path)
|
||||
# Should return empty dict on error
|
||||
assert isinstance(result, dict)
|
||||
finally:
|
||||
os.unlink(file_path)
|
||||
587
app/modules/messaging/tests/unit/test_messaging_service.py
Normal file
587
app/modules/messaging/tests/unit/test_messaging_service.py
Normal file
@@ -0,0 +1,587 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user