"""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='۰۹۱۲۱۱۱۰۰۰۱')