Files
customerTicket/tests/test_payment_receipt_alerts.py
T

316 lines
18 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_disabled_channels_do_not_notify_customer_or_staff_uploads(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)
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('picker,linked', [(False, True), (False, False), (True, True), (True, False)])
def test_staff_entry_points_enqueue_once_with_customer_identity(case, enabled, client, monkeypatch, picker, linked):
network = Mock(side_effect=AssertionError('Network must not run in registration'))
monkeypatch.setattr(requests, 'post', network)
client.force_login(case['creator'])
endpoint = reverse('staff_payments:receipt_new', args=[case['customer'].public_id])
if picker:
response = client.post(reverse('staff_payments:receipt_customer'), {'customer': case['customer'].pk})
assert response.status_code == 302 and response.url == endpoint
assert client.get(endpoint).status_code == 200
values = data(case, linked=linked)
invalid = client.post(endpoint, {**values, 'paid_amount': '0', 'receipt_image': upload()},
HTTP_ACCEPT='application/json')
assert invalid.status_code == 422
assert not PaymentReceiptAlert.objects.exists()
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'])
assert receipt.submitted_by_id == case['creator'].pk
assert receipt.notice_id == (case['notice'].pk if linked else None)
alerts = list(receipt.revisions.get().alerts.select_related('job'))
assert len(alerts) == 3
assert {a.job.channel for a in alerts} == {'SMS', 'BALE'}
assert {a.recipient_mobile for a in alerts} == {'', '+989121110001', '+989121110002'}
assert all(a.job.customer_id == case['customer'].pk for a in alerts)
assert all(a.context['customer_mobile'] == case['customer'].local_mobile for a in alerts)
assert all('واریزکننده آزمایشی' in a.context['customer_name'] for a in alerts)
assert all(a.context['receipt_link'].endswith(reverse('staff_payments:receipt_detail', args=[receipt.public_id])) for a in alerts)
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()
@pytest.mark.parametrize('staff', [False, True])
def test_customer_corrections_have_separate_idempotent_alerts_and_can_be_disabled(case, enabled, staff):
receipt = submit(case)
actor = case['creator'] if staff else case['customer']
if staff:
grant(actor, 'submit_customer_receipt')
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(actor, receipt.public_id, 1, form.cleaned_data)
assert PaymentReceiptAlert.objects.count() == 6
receipt_alerts.safe_queue_receipt_alerts(receipt.revisions.get(version=2), actor)
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(actor, receipt.public_id, 2, form.cleaned_data)
assert PaymentReceiptAlert.objects.count() == 6
@pytest.mark.parametrize('staff', [False, True])
def test_outer_rollback_removes_receipt_and_alerts(case, enabled, staff):
with pytest.raises(RuntimeError), transaction.atomic():
if staff:
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
else:
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
@pytest.mark.parametrize('staff', [False, True])
def test_worker_sends_private_photo_with_database_token_and_sms_to_managers(case, enabled, monkeypatch, settings, staff):
settings.BALE_BOT_TOKEN = 'existing-ticket-token'
if staff:
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
receipt = services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
else:
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='۰۹۱۲۱۱۱۰۰۰۱')