270 lines
15 KiB
Python
270 lines
15 KiB
Python
"""Receipt alert integration regressions; providers are always mocked."""
|
|
from datetime import timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
import requests
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.management import call_command
|
|
from django.db import IntegrityError, transaction
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from apps.core.templatetags.jalali import jalali_datetime
|
|
from apps.integrations.bale.providers import BaleBotProvider
|
|
from apps.integrations.sms.base import SMSDeliveryResult, SMSResult
|
|
from apps.notifications.management.commands import process_notifications as worker
|
|
from apps.notifications.models import NotificationJob
|
|
from apps.payments import receipt_alerts, services
|
|
from apps.payments.alert_templates import validate_alert_template
|
|
from apps.payments.forms import ReceiptAlertConfigurationForm, ReceiptForm
|
|
from apps.payments.models import (PaymentAuditEvent, PaymentReceipt, PaymentReceiptAlert,
|
|
PaymentReceiptAlertConfiguration, PaymentReceiptAlertRecipient)
|
|
from tests.test_payment_links import open_notice
|
|
from tests.test_payments import case, data, grant, submit, upload, PNG
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
@pytest.fixture
|
|
def enabled():
|
|
config = PaymentReceiptAlertConfiguration.objects.create(bale_enabled=True, bale_token='private-payment-token',
|
|
bale_chat_id='receipt-group', sms_enabled=True)
|
|
for name, mobile, active in [('مدیر اول', '09121110001', True), ('مدیر دوم', '09121110002', True), ('خاموش', '09121110003', False)]:
|
|
PaymentReceiptAlertRecipient.objects.create(configuration=config, name=name, mobile=mobile, is_active=active)
|
|
return config
|
|
|
|
|
|
def run(job):
|
|
call_command('process_notifications', once=True, job_id=job.pk)
|
|
job.refresh_from_db()
|
|
return job
|
|
|
|
|
|
def test_initially_disabled_and_staff_uploads_do_not_notify(case, enabled, monkeypatch):
|
|
network = Mock(side_effect=AssertionError('Network must not run in registration'))
|
|
monkeypatch.setattr(requests, 'post', network)
|
|
enabled.bale_enabled = enabled.sms_enabled = False
|
|
enabled.save()
|
|
submit(case)
|
|
enabled.bale_enabled = enabled.sms_enabled = True
|
|
enabled.save()
|
|
form = ReceiptForm(data(case, linked=False), {'receipt_image': upload()}, customer=case['customer'])
|
|
assert form.is_valid(), form.errors
|
|
services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
|
|
assert not PaymentReceiptAlert.objects.exists()
|
|
network.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize('anonymous,linked', [(False, True), (False, False), (True, True), (True, False)])
|
|
def test_all_customer_entry_points_enqueue_once_without_network(case, enabled, client, monkeypatch, anonymous, linked):
|
|
network = Mock(side_effect=AssertionError('Network must not run in registration'))
|
|
monkeypatch.setattr(requests, 'post', network)
|
|
case['customer'].first_name = 'مشتری'
|
|
case['customer'].last_name = 'آزمایشی'
|
|
case['customer'].save()
|
|
if anonymous:
|
|
open_notice(client, case)
|
|
else:
|
|
client.force_login(case['customer'])
|
|
values = data(case, linked=linked)
|
|
endpoint = reverse('payments:receipt_new')
|
|
for _ in range(2):
|
|
response = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
|
|
assert response.status_code == 200, response.content
|
|
receipt = PaymentReceipt.objects.get(customer=case['customer'])
|
|
alerts = list(PaymentReceiptAlert.objects.filter(revision__receipt=receipt))
|
|
assert len(alerts) == 3
|
|
assert {a.recipient_mobile for a in alerts} == {'', '+989121110001', '+989121110002'}
|
|
assert alerts[0].context['customer_name'] == 'مشتری آزمایشی'
|
|
assert alerts[0].context['customer_mobile'] == '09120009999'
|
|
assert alerts[0].context['received_at'] == jalali_datetime(receipt.revisions.get().created_at)
|
|
assert alerts[0].context['amount_rial'] == '12,345'
|
|
assert all(enabled.bale_token not in str(a.context) + a.message_template + str(a.job.payload) for a in alerts)
|
|
network.assert_not_called()
|
|
|
|
|
|
def test_customer_corrections_have_separate_idempotent_alerts_and_can_be_disabled(case, enabled):
|
|
receipt = submit(case)
|
|
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خوانا نیست')
|
|
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
|
|
assert form.is_valid(), form.errors
|
|
services.revise_receipt(case['customer'], receipt.public_id, 1, form.cleaned_data)
|
|
assert PaymentReceiptAlert.objects.count() == 6
|
|
receipt_alerts.safe_queue_receipt_alerts(receipt.revisions.get(version=2), case['customer'])
|
|
assert PaymentReceiptAlert.objects.count() == 6
|
|
enabled.notify_revisions = False
|
|
enabled.save()
|
|
services.decide_receipt(case['first'], receipt.public_id, 2, 'REJECT', 'اصلاح لازم است')
|
|
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
|
|
assert form.is_valid()
|
|
services.revise_receipt(case['customer'], receipt.public_id, 2, form.cleaned_data)
|
|
assert PaymentReceiptAlert.objects.count() == 6
|
|
|
|
|
|
def test_outer_rollback_removes_receipt_and_alerts(case, enabled):
|
|
with pytest.raises(RuntimeError), transaction.atomic():
|
|
submit(case)
|
|
raise RuntimeError('rollback')
|
|
assert not PaymentReceipt.objects.exists() and not PaymentReceiptAlert.objects.exists()
|
|
assert not NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT).exists()
|
|
|
|
|
|
def test_destination_database_failure_does_not_break_receipt_or_other_destinations(case, enabled, monkeypatch, caplog):
|
|
original = PaymentReceiptAlert.objects.get_or_create
|
|
def fail_group(**kwargs):
|
|
if kwargs['job'].channel == 'BALE':
|
|
raise IntegrityError('secret must not be logged')
|
|
return original(**kwargs)
|
|
monkeypatch.setattr(PaymentReceiptAlert.objects, 'get_or_create', fail_group)
|
|
receipt = submit(case)
|
|
assert receipt.revisions.count() == 1 and PaymentReceiptAlert.objects.count() == 2
|
|
assert not NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT, channel='BALE').exists()
|
|
assert 'IntegrityError' in caplog.text and 'secret must not be logged' not in caplog.text
|
|
|
|
|
|
def test_configuration_failure_isolated_from_registration(case, enabled, monkeypatch):
|
|
monkeypatch.setattr(receipt_alerts, 'queue_receipt_alerts', Mock(side_effect=IntegrityError('unavailable')))
|
|
receipt = submit(case)
|
|
assert PaymentReceipt.objects.filter(pk=receipt.pk).exists()
|
|
assert receipt.revisions.count() == 1
|
|
|
|
|
|
def test_worker_sends_private_photo_with_database_token_and_sms_to_managers(case, enabled, monkeypatch, settings):
|
|
settings.BALE_BOT_TOKEN = 'existing-ticket-token'
|
|
receipt = submit(case)
|
|
posted, sent = [], []
|
|
def post(url, **kwargs):
|
|
posted.append((url, kwargs['data'], kwargs['files']['photo'][1].read(), kwargs['timeout']))
|
|
return SimpleNamespace(ok=True, json=lambda: {'ok': True, 'result': {'message_id': 72}})
|
|
class Provider:
|
|
def send_message(self, mobile, body):
|
|
sent.append((mobile, body))
|
|
return SMSResult(True, 'manager-sms-id')
|
|
def get_delivery_status(self, provider_id):
|
|
return SMSDeliveryResult('DELIVERED', '0', 'رسیده به گوشی مدیر', True)
|
|
monkeypatch.setattr(requests, 'post', post)
|
|
monkeypatch.setattr(worker, 'get_sms_provider', lambda: Provider())
|
|
for alert in receipt.revisions.get().alerts.select_related('job'):
|
|
assert run(alert.job).status == 'SENT'
|
|
assert len(posted) == 1 and posted[0][0].endswith('/botprivate-payment-token/sendPhoto')
|
|
assert posted[0][1]['chat_id'] == 'receipt-group' and posted[0][2] == PNG
|
|
assert 'نام ثبت نشده' in posted[0][1]['caption'] and '09120009999' in posted[0][1]['caption']
|
|
assert jalali_datetime(receipt.revisions.get().created_at) in posted[0][1]['caption']
|
|
assert len(sent) == 2 and {mobile for mobile, _ in sent} == {'+989121110001', '+989121110002'}
|
|
assert all('12,345' in body and '09120009999' in body for _, body in sent)
|
|
sms = NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT, channel='SMS').first()
|
|
assert sms.delivery_status == 'PENDING'
|
|
NotificationJob.objects.filter(pk=sms.pk).update(delivery_next_check_at=timezone.now() - timedelta(seconds=1))
|
|
assert run(sms).delivery_status == 'DELIVERED'
|
|
assert len(sent) == 2
|
|
|
|
|
|
@pytest.mark.parametrize('channel', ['BALE', 'SMS'])
|
|
def test_outages_retry_bounded_and_preserve_receipt(case, enabled, monkeypatch, settings, channel):
|
|
settings.SMS_MAX_RETRIES = 1
|
|
settings.MAX_NOTIFICATION_ATTEMPTS = 2
|
|
receipt = submit(case)
|
|
monkeypatch.setattr(requests, 'post', Mock(side_effect=requests.Timeout('private-payment-token')))
|
|
monkeypatch.setattr(worker, 'get_sms_provider', lambda: SimpleNamespace(send_message=Mock(side_effect=RuntimeError('private-payment-token'))))
|
|
job = PaymentReceiptAlert.objects.filter(job__channel=channel).first().job
|
|
run(job)
|
|
assert job.status == ('RETRYING' if channel == 'SMS' else 'FAILED') and job.attempts == 1
|
|
assert 'private-payment-token' not in job.last_error
|
|
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now())
|
|
run(job)
|
|
assert job.status == 'FAILED' and job.attempts == 2
|
|
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now())
|
|
run(job)
|
|
assert job.attempts == 2 and receipt.revisions.get().image.storage.exists(receipt.revisions.get().image.name)
|
|
|
|
|
|
@pytest.mark.parametrize('change', ['bale_off', 'sms_off', 'group_changed', 'mobile_changed', 'manager_off'])
|
|
def test_pending_jobs_respect_switches_and_destination_changes(case, enabled, monkeypatch, change):
|
|
submit(case)
|
|
channel = 'BALE' if change in ['bale_off', 'group_changed'] else 'SMS'
|
|
alert = PaymentReceiptAlert.objects.filter(job__channel=channel).first()
|
|
if change == 'bale_off': enabled.bale_enabled = False
|
|
if change == 'sms_off': enabled.sms_enabled = False
|
|
if change == 'group_changed': enabled.bale_chat_id = 'other-group'
|
|
enabled.save()
|
|
if change in ['mobile_changed', 'manager_off']:
|
|
recipient = alert.recipient
|
|
if change == 'mobile_changed': recipient.mobile = '09124440000'
|
|
else: recipient.is_active = False
|
|
recipient.save()
|
|
post = Mock(side_effect=AssertionError('Cancelled destination'))
|
|
provider = Mock(side_effect=AssertionError('Cancelled destination'))
|
|
monkeypatch.setattr(requests, 'post', post)
|
|
monkeypatch.setattr(worker, 'get_sms_provider', provider)
|
|
assert run(alert.job).status == 'CANCELLED'
|
|
post.assert_not_called(); provider.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize('payload', [[], {'ok': False}, {'ok': True, 'result': {}}, {'ok': True, 'result': None}])
|
|
def test_malformed_bale_response_never_becomes_success(monkeypatch, payload):
|
|
monkeypatch.setattr(requests, 'post', lambda *a, **k: SimpleNamespace(ok=True, json=lambda: payload))
|
|
assert not BaleBotProvider().send_photo('group', upload(), token='secret').success
|
|
|
|
|
|
@pytest.mark.parametrize('template', ['{customer_name.__class__}', '{customer_name[0]}', '{amount_rial:99}', '{customer_name!r}', '{unknown}', '{}', '{broken', ''])
|
|
def test_templates_reject_unsafe_or_missing_tags(template):
|
|
with pytest.raises(ValidationError):
|
|
validate_alert_template(template)
|
|
|
|
|
|
def config_values(config, **extra):
|
|
return {'bale_enabled': config.bale_enabled, 'bale_token': '', 'bale_chat_id': config.bale_chat_id,
|
|
'bale_caption': config.bale_caption, 'sms_enabled': config.sms_enabled,
|
|
'sms_template': config.sms_template, 'notify_revisions': config.notify_revisions, **extra}
|
|
|
|
|
|
def test_configuration_form_keeps_hides_replaces_and_clears_token(enabled):
|
|
assert enabled.bale_token not in ReceiptAlertConfigurationForm(instance=enabled).as_p()
|
|
for value, expected in [('', enabled.bale_token), ('rotated-token', 'rotated-token')]:
|
|
form = ReceiptAlertConfigurationForm(config_values(enabled, bale_token=value), instance=enabled)
|
|
assert form.is_valid(), form.errors
|
|
assert form.save().bale_token == expected
|
|
form = ReceiptAlertConfigurationForm(config_values(enabled, clear_bale_token=True), instance=enabled)
|
|
assert not form.is_valid()
|
|
enabled.refresh_from_db()
|
|
form = ReceiptAlertConfigurationForm(config_values(enabled, bale_enabled=False, clear_bale_token=True), instance=enabled)
|
|
assert form.is_valid(), form.errors
|
|
assert form.save().bale_token == ''
|
|
|
|
|
|
def test_admin_can_manage_configuration_and_recipients_without_exposing_token(case, enabled, client):
|
|
user = case['creator']; user.is_staff = True; user.save()
|
|
grant(user, 'manage_receipt_alerts')
|
|
client.force_login(user)
|
|
endpoint = reverse('admin:payments_paymentreceiptalertconfiguration_change', args=[1])
|
|
page = client.get(endpoint)
|
|
assert page.status_code == 200 and enabled.bale_token not in page.content.decode()
|
|
recipients = list(enabled.recipients.all())
|
|
values = config_values(enabled, sms_template='مدیر {manager_name}: {amount_rial} ریال از {customer_mobile}')
|
|
values.update({'recipients-TOTAL_FORMS': len(recipients), 'recipients-INITIAL_FORMS': len(recipients),
|
|
'recipients-MIN_NUM_FORMS': 0, 'recipients-MAX_NUM_FORMS': 1000, '_save': 'Save'})
|
|
for i, recipient in enumerate(recipients):
|
|
values.update({f'recipients-{i}-id': recipient.pk, f'recipients-{i}-configuration': 1,
|
|
f'recipients-{i}-name': recipient.name, f'recipients-{i}-mobile': recipient.mobile})
|
|
if recipient.is_active: values[f'recipients-{i}-is_active'] = 'on'
|
|
response = client.post(endpoint, values)
|
|
assert response.status_code == 302, response.context['adminform'].form.errors if response.status_code == 200 else response.content
|
|
enabled.refresh_from_db()
|
|
assert enabled.bale_token == 'private-payment-token' and enabled.sms_template == values['sms_template']
|
|
audit = PaymentAuditEvent.objects.filter(action='CONFIG_SAVED').latest('pk')
|
|
assert enabled.bale_token not in str(audit.details)
|
|
submit(case)
|
|
assert client.get(reverse('admin:payments_paymentreceiptalert_changelist')).status_code == 200
|
|
assert client.get(reverse('admin:payments_paymentreceiptalert_change', args=[PaymentReceiptAlert.objects.first().pk])).status_code == 200
|
|
client.force_login(case['outsider'])
|
|
assert client.get(endpoint).status_code in [302, 403]
|
|
|
|
|
|
def test_recipient_invalid_mobile_is_validation_error_and_normalization_deduplicates(enabled):
|
|
with pytest.raises(ValidationError):
|
|
PaymentReceiptAlertRecipient.objects.create(configuration=enabled, name='نام', mobile='123')
|
|
with pytest.raises(IntegrityError), transaction.atomic():
|
|
PaymentReceiptAlertRecipient.objects.create(configuration=enabled, name='نام', mobile='۰۹۱۲۱۱۱۰۰۰۱')
|