fix payment

This commit is contained in:
javad committed 2026-10-07 13:39:29 +03:30
1 parent 4f7486152c
commit bb3ddd1610
47 files changed
+2137 -51

No files matched your search

+25
View File
@@ -4,6 +4,31 @@ from .payments import PaymentsPage
class PaymentLinkPage(PaymentsPage):
def prepare_copy(self, manual=False):
self.manual_copy = manual
if manual:
self.page.add_init_script("Object.defineProperty(navigator, 'clipboard', {value: {writeText: async () => {throw new Error('Clipboard unavailable');}}});")
else:
self.page.context.grant_permissions(['clipboard-read', 'clipboard-write'])
def copied_text(self, button_name):
self.page.get_by_role('button', name=button_name, exact=True).click()
if button_name == 'کپی لینک کوتاه این صفحه برای ارسال به دیگران':
expect(self.page.locator('.pay-share-feedback')).to_contain_text('لینک آماده است')
if self.manual_copy:
dialog = self.page.get_by_role('dialog')
expect(dialog).to_be_visible()
value = dialog.locator('textarea').input_value()
dialog.get_by_role('button', name='بستن', exact=True).click()
return value
expect(self.page.get_by_text('کپی شد', exact=True)).to_be_visible()
return self.page.evaluate('navigator.clipboard.readText()')
def copy_short_link(self):
value = self.copied_text('کپی لینک کوتاه این صفحه برای ارسال به دیگران')
expect(self.page.locator('.pay-share-feedback')).to_contain_text('لینک آماده است')
return value
def open_link(self, path):
self.goto(path)
expect(self.page.get_by_role('heading', name='جزئیات واریز', exact=True)).to_be_visible()
+79
View File
@@ -0,0 +1,79 @@
from playwright.sync_api import expect
from .base import BasePage
class ReceiptUploadPage(BasePage):
def control_transport(self):
"""Pause the browser transport while keeping the final POST real."""
self.page.add_init_script("""(() => {
const Native = window.XMLHttpRequest;
window.receiptTransport = {count: 0};
window.XMLHttpRequest = class extends Native {
open(method, url, ...rest) {
this.receiptRequest = method === 'POST' && /payment-receipt|receipts/.test(url);
super.open(method, url, ...rest);
}
send(payload) {
if (!this.receiptRequest) return super.send(payload);
window.receiptTransport.count++;
window.receiptTransport.current = this;
this.release = () => super.send(payload);
this.upload.dispatchEvent(new ProgressEvent('progress', {
lengthComputable: true, loaded: 524288, total: 1048576
}));
}
};
})();""")
def fill_receipt(self, account_id, png):
self.page.locator('#id_paid_amount').fill('۱۲۳۴۵')
self.page.locator('#id_destination_account').select_option(str(account_id))
self.page.get_by_role('button', name='امروز', exact=True).click()
self.page.locator('#id_receipt_image').set_input_files({'name': 'receipt.png', 'mimeType': 'image/png', 'buffer': png})
def submit(self):
self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click()
def assert_uploading(self):
expect(self.page.locator('#receipt-upload')).to_be_visible()
expect(self.page.locator('#receipt-upload-percent')).to_have_text('۵۰٪')
expect(self.page.locator('#receipt-upload-bytes')).to_have_text('ارسال‌شده: ۵۱۲ کیلوبایت از ۱ مگابایت')
expect(self.page.locator('#receipt-upload-time')).to_contain_text('دیگر برای ارسال')
expect(self.page.locator('#submit-receipt')).to_be_disabled()
expect(self.page.locator('#id_receipt_image')).to_be_disabled()
def repeat_submission(self):
self.page.locator('#receipt-form').evaluate("form => {form.requestSubmit(); form.requestSubmit();}")
assert self.page.evaluate('window.receiptTransport.count') == 1
def processing(self):
self.page.evaluate("window.receiptTransport.current.upload.dispatchEvent(new Event('load'))")
expect(self.page.locator('#receipt-upload-percent')).to_have_text('۱۰۰٪')
expect(self.page.locator('#receipt-upload-title')).to_contain_text('در حال بررسی و ثبت فیش')
expect(self.page.locator('#submit-receipt')).to_be_disabled()
def fail_network(self):
self.page.evaluate("window.receiptTransport.current.dispatchEvent(new Event('error'))")
expect(self.page.locator('#receipt-errors')).to_contain_text('ارتباط با سرور قطع شد')
expect(self.page.locator('#submit-receipt')).to_be_enabled()
expect(self.page.locator('#id_receipt_image')).to_be_enabled()
assert self.page.locator('#id_receipt_image').evaluate('input => input.files.length') == 1
def reject_fields(self):
self.page.evaluate("""() => {
const request = window.receiptTransport.current;
Object.defineProperty(request, 'status', {value: 422});
Object.defineProperty(request, 'response', {value: {errors: {
paid_amount: [{message: 'مبلغ با پرداخت مطابقت ندارد.'}],
__all__: [{message: 'اطلاعات بانکی را دوباره بررسی کنید.'}]
}}});
request.dispatchEvent(new Event('load'));
}""")
expect(self.page.locator('#receipt-errors')).to_contain_text('اطلاعات بانکی را دوباره بررسی کنید.')
expect(self.page.locator('#error-paid_amount')).to_have_text('مبلغ با پرداخت مطابقت ندارد.')
expect(self.page.locator('#submit-receipt')).to_be_enabled()
def finish(self, revision=False):
self.page.evaluate('window.receiptTransport.current.release()')
expect(self.page.locator('.flash')).to_contain_text('نسخه اصلاحی ثبت شد' if revision else 'فیش ثبت شد و در انتظار بررسی است.')
+44
View File
@@ -0,0 +1,44 @@
from django.urls import reverse
from playwright.sync_api import expect
from .base import BasePage
class StaffPaymentsPage(BasePage):
def open_desk(self, parameters=''):
self.goto(reverse('staff_payments:desk') + parameters)
def assert_action_tab(self):
expect(self.page.get_by_role('link', name='نیازمند اقدام من', exact=True)).to_have_attribute('aria-current', 'page')
expect(self.page.get_by_role('heading', name='فیش‌های نیازمند اقدام شما')).to_be_visible()
def search(self, query):
self.page.locator('[name=q]').fill(query)
self.page.get_by_role('button', name='جست‌وجو', exact=True).click()
def next_page(self):
pagination = self.page.get_by_role('navigation', name='صفحه‌بندی', exact=True)
expect(pagination).to_contain_text('صفحه 1 از 2')
pagination.get_by_role('link', name='بعدی', exact=True).click()
expect(pagination).to_contain_text('صفحه 2 از 2')
def open_notice(self, public_id):
self.goto(reverse('staff_payments:notice_detail', args=[public_id]))
def inspect_history(self):
panel = self.page.locator('.pay-sms-panel')
expect(panel).to_contain_text('رسیده به گوشی')
expect(panel).to_contain_text('پذیرفته‌شده توسط سرویس پیامک')
expect(panel).to_contain_text('اعتبار پنل کافی نیست')
for details in panel.locator('details').all():
details.locator('summary').click()
expect(panel).to_contain_text('old-delivered-id')
expect(panel).to_contain_text('کد پاسخ سرویس: -19')
def resend_sms(self):
button = self.page.get_by_role('button', name='ارسال مجدد پیامک اعلامیه واریز', exact=True)
expect(button).to_be_enabled()
button.click()
expect(self.page.locator('.flash')).to_contain_text('درخواست ارسال مجدد پیامک در صف ثبت شد')
expect(button).to_be_disabled()
expect(self.page.locator('.pay-sms-history .pay-sms-record')).to_have_count(3)
+37 -1
View File
@@ -7,11 +7,47 @@ from apps.core.models import SiteBranding
from apps.payments.access import issue_payment_link
from apps.payments import services
from tests.e2e.pages.payment_link import PaymentLinkPage
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.test_payments_e2e import payment_case, PNG
from tests.e2e.test_payments_e2e import PASSWORD
from django.urls import reverse
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize('width,via_link,manual', [(320, True, True), (1440, True, False),
(320, False, False), (1440, False, True)])
def test_notice_sharing_and_full_copy(live_server, browser_page, payment_case, width, via_link, manual):
customer, account, reviewers = payment_case
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
'subject': 'اشتراک اطلاعات واریز', 'amount': 12345, 'deposit_id': '۰۰۰۱۲۳۰۴'})
_, original_url = issue_payment_link(notice=notice)
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
view = PaymentLinkPage(page, live_server.url)
view.prepare_copy(manual)
if via_link:
view.open_link(urlsplit(original_url).path)
else:
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
view.goto(reverse('payments:notice_detail', args=[notice.public_id]))
expect(page.locator('.pay-deposit-required dd')).to_have_text('12304')
full_copy = view.copied_text('کپی کامل اطلاعات')
assert 'شناسه واریز: 12304' in full_copy
assert 'وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است' in full_copy
assert 'شماره حساب: 000123' in full_copy
shared_url = view.copy_short_link()
assert urlsplit(shared_url).path.startswith('/p/')
if via_link:
assert shared_url == original_url
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
with browser_page() as recipient_page:
recipient = PaymentLinkPage(recipient_page, live_server.url)
recipient.open_link(urlsplit(shared_url).path)
expect(recipient_page.locator('.pay-deposit-required dd')).to_have_text('12304')
expect(recipient_page.get_by_role('link', name='پروفایل من')).to_have_count(0)
@pytest.mark.parametrize('width,linked', [(1440, True), (320, True), (390, False)])
def test_sms_payment_without_login_preserves_copy_and_submission(live_server, browser_page, payment_case, width, linked):
customer, account, reviewers = payment_case
@@ -25,7 +61,7 @@ def test_sms_payment_without_login_preserves_copy_and_submission(live_server, br
page.set_viewport_size({'width': width, 'height': 900})
view = PaymentLinkPage(page, live_server.url)
view.open_link(urlsplit(url).path)
expect(page.locator('.pay-deposit-required dd')).to_have_text('000123')
expect(page.locator('.pay-deposit-required dd')).to_have_text('123')
expect(page.get_by_text('واریز بدون این شناسه معتبر نخواهد بود.', exact=False)).to_be_visible()
expect(page.locator('.cs-footer')).to_have_text(branding.developer_credit_text)
expect(page.get_by_role('link', name='پروفایل من')).to_have_count(0)
+4 -4
View File
@@ -150,7 +150,7 @@ def test_staff_issues_identifier_notice_and_customer_sends_linked_receipt(live_s
page.get_by_role('link',name='ارسال فیش این واریز',exact=True).click()
expect(page.get_by_label('واریز مرتبط (پیشنهادی)',exact=True)).to_be_checked()
expect(page.locator('#id_destination_account')).to_have_value(str(account.pk))
expect(page.locator('#id_deposit_id')).to_have_value('00123')
expect(page.locator('#id_deposit_id')).to_have_value('123')
page.locator('#id_paid_amount').fill('100000')
page.get_by_role('button',name='امروز',exact=True).click()
page.locator('#id_receipt_image').set_input_files({'name':'receipt.png','mimeType':'image/png','buffer':PNG})
@@ -362,13 +362,13 @@ def test_notice_identifier_generation_preserves_manual_value_and_customer_contex
page.locator('#id_account').select_option(str(account.pk))
expect(page.locator('#id_deposit_id')).to_have_attribute('required', '')
generate.click()
expect(page.locator('#id_deposit_id')).to_have_value('0912000902169')
expect(page.locator('#id_deposit_id')).to_have_value('912000902169')
page.locator('#id_deposit_id').fill('manual-123')
generate.click()
expect(page.locator('#identifier-candidate')).to_have_text('0912000902169')
expect(page.locator('#identifier-candidate')).to_have_text('912000902169')
expect(page.locator('#id_deposit_id')).to_have_value('manual-123')
page.get_by_role('button', name='جایگزینی شناسه', exact=True).click()
expect(page.locator('#id_deposit_id')).to_have_value('0912000902169')
expect(page.locator('#id_deposit_id')).to_have_value('912000902169')
page.get_by_role('button', name='تغییر مشتری', exact=True).click()
expect(page.locator('#id_deposit_id')).to_have_value('')
expect(generate).to_be_disabled()
+101
View File
@@ -0,0 +1,101 @@
from urllib.parse import urlsplit
import uuid
import jdatetime
import pytest
from django.contrib.auth.models import Permission
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from playwright.sync_api import expect
from apps.payments import services
from apps.payments.access import issue_payment_link
from apps.payments.models import PaymentReceipt
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.receipt_upload import ReceiptUploadPage
from tests.e2e.test_payments_e2e import payment_case, PNG, PASSWORD
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize('mode,width', [('independent', 320), ('linked', 1440), ('staff', 320), ('link', 320)])
def test_receipt_upload_progress_processing_and_single_submission(live_server, browser_page, payment_case, mode, width):
customer, account, reviewers = payment_case
creator = reviewers[0]
creator.user_permissions.add(Permission.objects.get(content_type__app_label='payments', codename='add_paymentnotice'))
notice = services.issue_notice(creator, {'customer': customer, 'account': account, 'subject': 'تست پیشرفت ارسال', 'amount': 12345})
_, link_url = issue_payment_link(notice=notice)
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
view = ReceiptUploadPage(page, live_server.url)
view.control_transport()
if mode == 'staff':
AuthenticationPage(page, live_server.url).login_staff(creator, PASSWORD)
view.goto(reverse('staff_payments:receipt_new', args=[customer.public_id]))
elif mode == 'link':
view.goto(urlsplit(link_url).path)
page.get_by_role('link', name='ارسال فیش این واریز', exact=True).click()
else:
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
view.goto(reverse('payments:receipt_new') + ('?notice=' + str(notice.public_id) if mode == 'linked' else ''))
if mode in ('independent', 'staff'):
page.get_by_label('واریز مستقل', exact=True).check()
view.fill_receipt(account.pk, PNG)
view.submit()
view.assert_uploading()
view.repeat_submission()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
view.processing()
view.finish()
assert PaymentReceipt.objects.filter(customer=customer).count() == 1
@pytest.mark.parametrize('failure', ['network', 'validation'])
def test_failed_upload_retains_image_and_can_retry(live_server, browser_page, payment_case, failure):
customer, account, _ = payment_case
with browser_page() as page:
view = ReceiptUploadPage(page, live_server.url)
view.control_transport()
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
view.goto(reverse('payments:receipt_new'))
page.get_by_label('واریز مستقل', exact=True).check()
view.fill_receipt(account.pk, PNG)
key = page.locator('[name=submission_key]').input_value()
view.submit()
view.assert_uploading()
view.fail_network() if failure == 'network' else view.reject_fields()
expect(page.locator('#id_paid_amount')).to_have_value('۱۲٬۳۴۵')
assert page.locator('[name=submission_key]').input_value() == key
view.submit()
expect(page.locator('#receipt-upload-title')).to_have_text('در حال ارسال فیش…')
view.finish()
assert PaymentReceipt.objects.filter(customer=customer).count() == 1
def test_revised_receipt_uses_upload_progress(live_server, browser_page, payment_case, client):
customer, account, reviewers = payment_case
today = jdatetime.date.fromgregorian(date=timezone.localdate())
client.force_login(customer)
response = client.post(reverse('payments:receipt_new'), {
'receipt_mode': 'independent', 'paid_amount': '12345', 'destination_account': account.pk,
'paid_day': str(today.day), 'paid_month': str(today.month), 'paid_year': str(today.year),
'submission_key': str(uuid.uuid4()),
'receipt_image': SimpleUploadedFile('receipt.png', PNG, content_type='image/png'),
})
assert response.status_code == 302
receipt = PaymentReceipt.objects.get(customer=customer)
services.decide_receipt(reviewers[0], receipt.public_id, 1, 'REJECT', 'تصویر واضح‌تر لازم است')
with browser_page() as page:
view = ReceiptUploadPage(page, live_server.url)
view.control_transport()
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
view.goto(reverse('payments:receipt_revise', args=[receipt.public_id]))
view.fill_receipt(account.pk, PNG)
view.submit()
view.assert_uploading()
view.repeat_submission()
view.processing()
view.finish(revision=True)
receipt.refresh_from_db()
assert receipt.current_version == 2 and receipt.revisions.count() == 2
+76
View File
@@ -0,0 +1,76 @@
import uuid
from urllib.parse import parse_qs, urlsplit
import jdatetime
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from apps.notifications.models import NotificationJob
from apps.payments import services
from apps.payments.models import PaymentNotification, PaymentNotificationAttempt
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.staff_payments import StaffPaymentsPage
from tests.e2e.test_payments_e2e import payment_case, PASSWORD, PNG
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize('notices', [False, True])
def test_staff_default_action_tab_and_pagination(live_server, browser_page, payment_case, client, notices):
customer, account, reviewers = payment_case
if notices:
for index in range(26):
services.issue_notice(reviewers[0], {'customer': customer, 'account': account, 'subject': f'اعلامیه صفحه بندی {index}', 'amount': 12345})
else:
client.force_login(customer)
today = jdatetime.date.fromgregorian(date=timezone.localdate())
for _ in range(26):
response = client.post(reverse('payments:receipt_new'), {'receipt_mode': 'independent',
'paid_amount': '12345', 'destination_account': account.pk, 'submission_key': str(uuid.uuid4()),
'paid_day': str(today.day), 'paid_month': str(today.month), 'paid_year': str(today.year),
'receipt_image': SimpleUploadedFile('receipt.png', PNG, content_type='image/png')})
assert response.status_code == 302
with browser_page() as page:
page.set_viewport_size({'width': 320, 'height': 900})
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
view = StaffPaymentsPage(page, live_server.url)
view.open_desk()
view.assert_action_tab()
if notices:
page.get_by_role('link', name='اعلامیه‌های واریز', exact=True).click()
view.search(customer.username)
view.next_page()
query = parse_qs(urlsplit(page.url).query)
assert query['q'] == [customer.username]
assert query['tab'] == ['notices' if notices else 'receipts']
if not notices:
assert query['mine'] == ['1']
view.assert_action_tab()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
@pytest.mark.parametrize('width', [320, 1440])
def test_staff_sms_history_and_resend(live_server, browser_page, payment_case, width):
customer, account, reviewers = payment_case
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account, 'subject': 'اعلامیه با سوابق پیامک', 'amount': 12345})
first = PaymentNotification.objects.get(notice=notice)
NotificationJob.objects.filter(pk=first.job_id).update(status='FAILED', attempts=1, last_error='اعتبار پنل کافی نیست')
PaymentNotificationAttempt.objects.create(notification=first, finished_at=timezone.now(), success=False,
provider_code='-19', error='اعتبار پنل کافی نیست')
delivered = services.resend_notice_sms(reviewers[0], notice, str(uuid.uuid4()))
NotificationJob.objects.filter(pk=delivered.pk).update(status='SENT', provider_message_id='old-delivered-id',
sent_at=timezone.now(), delivery_status='DELIVERED', delivery_is_final=True, delivery_checked_at=timezone.now())
PaymentNotificationAttempt.objects.create(notification=delivered.payment_notification, finished_at=timezone.now(),
success=True, provider_code='2', provider_id='old-delivered-id')
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
view = StaffPaymentsPage(page, live_server.url)
view.open_notice(notice.public_id)
view.inspect_history()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
view.resend_sms()
assert PaymentNotification.objects.filter(notice=notice).count() == 3
assert PaymentNotification.objects.filter(notice=notice, job__status='PENDING').count() == 1
+66 -1
View File
@@ -31,6 +31,71 @@ def open_notice(client, case):
return link
def test_customer_shares_short_link_once_without_sending_sms(case, client):
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
client.force_login(case['customer'])
jobs = NotificationJob.objects.count()
assert client.get(endpoint).status_code == 405
response = client.post(endpoint)
assert response.status_code == 200
assert response['Cache-Control'] == 'private, no-store'
url = response.json()['url']
assert re.fullmatch(r'/p/[a-z0-9]{10}/', urlsplit(url).path)
assert client.post(endpoint).json()['url'] == url
assert PaymentAccessLink.objects.count() == 1
assert NotificationJob.objects.count() == jobs
recipient = Client()
page = recipient.get(urlsplit(url).path, follow=True)
assert page.status_code == 200
assert case['notice'].number in page.content.decode()
assert '_auth_user_id' not in recipient.session
assert recipient.post(endpoint).json()['url'] == url
def test_anonymous_share_reuses_original_expiry_and_revocation(case, client):
link, url = issue_payment_link(notice=case['notice'])
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
client.get(urlsplit(url).path, follow=True)
before = link.expires_at
assert client.post(endpoint).json()['url'] == url
link.refresh_from_db()
assert link.expires_at == before
assert PaymentAccessLink.objects.count() == 1
link.revoked_at = timezone.now()
link.save(update_fields=['revoked_at'])
assert client.post(endpoint).status_code == 410
assert PaymentAccessLink.objects.count() == 1
def test_share_link_checks_owner_scope_and_csrf(case, client):
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
other = User.objects.create_user(username='other-share-owner', account_type='CUSTOMER')
client.force_login(other)
assert client.post(endpoint).status_code == 404
client.force_login(case['creator'])
assert client.post(endpoint).status_code == 403
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(case['customer'])
assert csrf_client.post(endpoint).status_code == 403
open_notice(client := Client(), case)
outside = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': 'اعلامیه خارج از لینک اشتراک', 'amount': 100, 'deposit_id': '00044'})
assert client.post(reverse('payments:notice_share_link', args=[outside.public_id])).status_code == 404
@pytest.mark.parametrize('with_identifier', [False, True])
def test_full_copy_mentions_required_identifier_only_when_present(case, client, with_identifier):
from apps.payments.models import PaymentNotice
PaymentNotice.objects.filter(pk=case['notice'].pk).update(deposit_id='۰۰۰۱۲۳' if with_identifier else '')
client.force_login(case['customer'])
response = client.get(reverse('payments:notice_detail', args=[case['notice'].public_id]))
text = response.context['notice'].copy_text
assert ('وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است' in text) == with_identifier
assert ('شناسه واریز: 123' in text) == with_identifier
assert 'شماره حساب: 000123456' in text
assert 'کپی لینک کوتاه این صفحه برای ارسال به دیگران' in response.content.decode()
def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settings):
settings.PAYMENT_LINK_TTL_DAYS = 3
job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
@@ -112,7 +177,7 @@ def test_public_notice_preserves_copy_warning_and_database_signature(case, clien
open_notice(client, case)
response = client.get(reverse('payments:notice_detail', args=[case['notice'].public_id]))
html = response.content.decode()
assert 'data-copy="000123"' in html
assert 'data-copy="123"' in html
assert 'کپی کامل اطلاعات' in html and 'pay-deposit-required' in html
assert case['account'].name in html
assert 'نام حساب: ' + case['account'].name in response.context['notice'].copy_text
+27
View File
@@ -1,6 +1,7 @@
"""PostgreSQL-only regression for simultaneous notice deletion/receipt submission."""
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from uuid import uuid4
import pytest
from django.core.exceptions import ValidationError
@@ -9,12 +10,38 @@ from django.db import close_old_connections, connection
from apps.payments import services
from apps.payments.forms import ReceiptForm
from apps.payments.models import PaymentReceipt
from apps.payments.models import PaymentNotification
from apps.notifications.models import NotificationJob
from tests.test_payments import case, data, upload
pytestmark = [pytest.mark.django_db(transaction=True),
pytest.mark.skipif(connection.vendor != 'postgresql', reason='Requires PostgreSQL row locks')]
@pytest.mark.parametrize('same_key', [True, False])
def test_simultaneous_notice_sms_resend_creates_one_job(case, same_key):
original = PaymentNotification.objects.get(notice=case['notice'])
NotificationJob.objects.filter(pk=original.job_id).update(status='FAILED')
first_key = str(uuid4())
keys = [first_key, first_key if same_key else str(uuid4())]
barrier = Barrier(2)
def resend(key):
close_old_connections()
try:
barrier.wait(timeout=10)
try:
return services.resend_notice_sms(case['creator'], case['notice'], key).pk
except ValidationError:
return 'blocked'
finally:
connection.close()
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = list(pool.map(resend, keys))
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
assert len(set(outcomes)) == (1 if same_key else 2)
assert outcomes.count('blocked') == (0 if same_key else 1)
def test_simultaneous_delete_and_receipt_allow_exactly_one_operation(case):
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
+269
View File
@@ -0,0 +1,269 @@
"""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='۰۹۱۲۱۱۱۰۰۰۱')
+94 -5
View File
@@ -25,6 +25,51 @@ def upload():
return SimpleUploadedFile('receipt.png', PNG, content_type='image/png')
@pytest.mark.parametrize('staff', [False, True])
def test_receipt_upload_json_success_keeps_flash_and_deduplicates(case, client, staff):
client.force_login(case['creator'] if staff else case['customer'])
endpoint = reverse('staff_payments:receipt_new', args=[case['customer'].public_id]) if staff else reverse('payments:receipt_new')
values = data(case)
first = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert first.status_code == 200
receipt = PaymentReceipt.objects.get(customer=case['customer'])
expected = reverse('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', args=[receipt.public_id])
assert first.json() == {'redirect_url': expected}
repeated = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert repeated.json() == first.json()
assert PaymentReceipt.objects.filter(customer=case['customer']).count() == 1
assert 'فیش ثبت شد و در انتظار بررسی است.' in client.get(expected).content.decode()
def test_receipt_upload_json_field_and_service_errors(case, client, monkeypatch):
client.force_login(case['customer'])
endpoint = reverse('payments:receipt_new')
invalid = client.post(endpoint, {**data(case), 'paid_amount': '0', 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert invalid.status_code == 422
assert invalid.json()['errors']['paid_amount'][0]['message']
def unavailable(*args, **kwargs):
raise ValidationError('اعلامیه دیگر قابل پرداخت نیست.')
monkeypatch.setattr(services, 'submit_receipt', unavailable)
response = client.post(endpoint, {**data(case), 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert response.status_code == 422
assert response.json()['errors']['__all__'][0]['message'] == 'اعلامیه دیگر قابل پرداخت نیست.'
assert not PaymentReceipt.objects.filter(customer=case['customer']).exists()
def test_receipt_revision_upload_json_errors_and_success(case, client):
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خوانا نیست')
client.force_login(case['customer'])
endpoint = reverse('payments:receipt_revise', args=[receipt.public_id])
stale = client.post(endpoint, {**data(case), 'version': '0', 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert stale.status_code == 422 and stale.json()['errors']['__all__']
valid = client.post(endpoint, {**data(case), 'version': '1', 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert valid.json() == {'redirect_url': reverse('payments:receipt_detail', args=[receipt.public_id])}
receipt.refresh_from_db()
assert receipt.current_version == 2 and receipt.revisions.count() == 2
assert 'نسخه اصلاحی ثبت شد' in client.get(valid.json()['redirect_url']).content.decode()
def grant(user, code):
user.user_permissions.add(Permission.objects.get(content_type__app_label='payments',codename=code))
for attr in ['_perm_cache','_user_perm_cache','_group_perm_cache']:
@@ -234,7 +279,7 @@ def test_independent_receipt_and_server_date(case,client):
assert receipt.notice is None
assert receipt.revisions.get().paid_date == timezone.localdate()
client.force_login(case['outsider'])
response = client.get(reverse('staff_payments:desk'),{'kind':'independent'})
response = client.get(reverse('staff_payments:desk'),{'tab':'receipts','kind':'independent'})
assert receipt.number in response.content.decode()
assert client.get(reverse('staff_payments:count')).json()['pending_receipts'] == 1
@@ -414,7 +459,7 @@ def test_revision_retains_notice_snapshot_and_identifier(case):
services.revise_receipt(case['customer'], receipt.public_id, 1, form.cleaned_data)
revision = receipt.revisions.get(version=2)
assert revision.account_snapshot['account_number'] == '000123456'
assert revision.deposit_id == '000123'
assert revision.deposit_id == '123'
def test_financial_unit_sms_template_accepts_financial_tags(case):
@@ -723,7 +768,7 @@ def test_linked_receipt_rejection_history_and_staff_identity(case, client):
staff = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])).content.decode()
assert all(value in staff for value in ['مریم', 'امینی', 'اصفهان', 'گلپایگان', case['customer'].local_mobile])
assert 'افراد تعیین‌شده' not in staff
listing = client.get(reverse('staff_payments:desk'), {'q': 'گلپایگان'}).content.decode()
listing = client.get(reverse('staff_payments:desk'), {'tab': 'receipts', 'q': 'گلپایگان'}).content.decode()
assert 'مریم' in listing and 'ایجادکننده اعلامیه: creator' in listing
@@ -787,7 +832,7 @@ def test_customer_notice_has_independent_owned_detail_url(case, client):
assert staff.context['notice_data'][0]['url'] == reverse('staff_payments:notice_detail', args=[case['notice'].public_id])
@pytest.mark.parametrize('mobile,expected', [('09120009999','0912000999938'), ('09120009021','0912000902169'), ('09000000000','0900000000090')])
@pytest.mark.parametrize('mobile,expected', [('09120009999','912000999938'), ('09120009021','912000902169'), ('09000000000','900000000090')])
def test_deposit_identifier_reference_vectors(mobile, expected):
from apps.payments.deposit_identifier import generate_id_algorithm
assert generate_id_algorithm(mobile) == expected
@@ -800,7 +845,7 @@ def test_deposit_identifier_endpoint_uses_stored_mobile_and_permissions(case, cl
before = case['customer'].payment_notices.count()
response = client.post(endpoint, {'customer': case['customer'].pk, 'mobile': '09000000000'})
assert response.status_code == 200
assert response.json()['identifier'] == '0912000999938'
assert response.json()['identifier'] == '912000999938'
assert response['Cache-Control'] == 'no-store'
assert case['customer'].payment_notices.count() == before
assert client.get(endpoint).status_code == 405
@@ -813,3 +858,47 @@ def test_deposit_identifier_endpoint_uses_stored_mobile_and_permissions(case, cl
client.force_login(case['creator'])
PaymentAccessConfiguration.objects.update_or_create(singleton=1, defaults={'open_create':False})
assert client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403
@pytest.mark.parametrize('value,expected', [('', ''), ('000', '0'), ('۰۰۰۱۲۳۰۴', '12304'),
('٠٠٩١٢٣٠٦', '912306'), (' 00123 ', '123'), ('manual-123', 'manual-123')])
def test_deposit_identifier_normalization(value, expected):
from apps.payments.deposit_identifier import normalize_deposit_identifier
assert normalize_deposit_identifier(value) == expected
def test_generator_strips_zeroes_after_calculating_check_digits():
from apps.payments.deposit_identifier import generate_id_algorithm
assert generate_id_algorithm('00009120009999') == '912000999938'
assert generate_id_algorithm('000') == '0'
assert generate_id_algorithm('invalid').startswith('Error:')
def test_notice_and_receipt_store_normalized_identifiers(case):
notice = case['notice']
notice.refresh_from_db()
assert notice.deposit_id == '123'
assert notice.account_snapshot['account_number'] == '000123456'
receipt = submit(case)
assert receipt.revisions.get(version=1).deposit_id == '123'
notice.deposit_id = '٠٠١٢٣٠٤'
notice.save(update_fields=['deposit_id'])
notice.refresh_from_db()
assert notice.deposit_id == '12304'
def test_existing_identifier_migration_preserves_bank_snapshot(case):
from importlib import import_module
from django.apps import apps
from django.db import connection
from apps.payments.models import PaymentNotice, PaymentReceiptRevision
receipt = submit(case)
PaymentNotice.objects.filter(pk=case['notice'].pk).update(deposit_id='۰۰۰۱۲۳۰۴')
PaymentReceiptRevision.objects.filter(receipt=receipt).update(deposit_id='00012304')
migration = import_module('apps.payments.migrations.0006_normalize_deposit_identifiers')
from types import SimpleNamespace
migration.normalize_existing_identifiers(apps, SimpleNamespace(connection=connection))
case['notice'].refresh_from_db()
assert case['notice'].deposit_id == '12304'
assert receipt.revisions.get(version=1).deposit_id == '12304'
assert case['notice'].account_snapshot['account_number'] == '000123456'
+153
View File
@@ -0,0 +1,153 @@
import uuid
from urllib.parse import parse_qs, urlsplit
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.management import call_command
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from apps.integrations.sms.base import SMSDeliveryResult, SMSResult
from apps.notifications.models import NotificationJob
from apps.notifications.management.commands import process_notifications
from apps.payments import services
from apps.payments.models import PaymentAuditEvent, PaymentAccessConfiguration, PaymentNotification, PaymentNotice
from tests.test_payments import case, submit, grant
pytestmark = pytest.mark.django_db
def test_default_action_tab_and_explicit_all_receipts(case, client):
receipt = submit(case)
client.force_login(case['first'])
first = client.get(reverse('staff_payments:desk'))
assert first.context['mine'] is True and receipt.number in first.content.decode()
assert 'صفحه 1 از 1' in first.content.decode()
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE')
assert receipt.number not in client.get(reverse('staff_payments:desk')).content.decode()
assert receipt.number in client.get(reverse('staff_payments:desk'), {'tab': 'receipts'}).content.decode()
client.force_login(case['outsider'])
assert receipt.number not in client.get(reverse('staff_payments:desk')).content.decode()
assert client.get(reverse('staff_payments:desk'), {'mine': '0'}).context['mine'] is False
@pytest.mark.parametrize('tab', ['notices', 'receipts', 'mine'])
def test_desk_pagination_preserves_tab_and_filters(case, client, tab):
if tab == 'notices':
for index in range(26):
services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': f'صفحه بندی {index}', 'amount': 12345, 'deposit_id': '123'})
else:
for _ in range(26):
submit(case)
client.force_login(case['first'])
parameters = {'q': case['customer'].username, 'account': case['account'].pk}
if tab != 'mine':
parameters['tab'] = tab
first = client.get(reverse('staff_payments:desk'), parameters)
assert len(first.context['page_obj']) == 25
query = parse_qs(first.context['query_string'])
assert query['tab'] == ['notices' if tab == 'notices' else 'receipts']
assert query.get('mine') == (['1'] if tab == 'mine' else None)
assert query['q'] == [case['customer'].username]
second = client.get(reverse('staff_payments:desk'), {**parameters, 'page': 2})
assert second.context['page_obj'].number == 2 and len(second.context['page_obj']) >= 1
assert not {item.pk for item in first.context['page_obj']} & {item.pk for item in second.context['page_obj']}
def test_resend_uses_worker_keeps_attempts_and_delivery_history(case, client, monkeypatch, settings):
settings.SMS_MAX_RETRIES = 0
sent = []
class Provider:
def send_message(self, mobile, body):
sent.append((mobile, body))
return SMSResult(False, error='اعتبار پنل کافی نیست', provider_code='-19') if len(sent) == 1 else SMSResult(True, 'new-sms-id', provider_code='2')
def get_delivery_status(self, provider_id):
assert provider_id == 'new-sms-id'
return SMSDeliveryResult('DELIVERED', '0', 'پیامک به گوشی رسید', True)
monkeypatch.setattr(process_notifications, 'get_sms_provider', lambda: Provider())
original = PaymentNotification.objects.get(notice=case['notice'])
call_command('process_notifications', once=True, job_id=original.job_id)
original.refresh_from_db()
original.job.refresh_from_db()
assert original.job.status == 'FAILED'
endpoint = reverse('staff_payments:notice_resend_sms', args=[case['notice'].public_id])
detail = reverse('staff_payments:notice_detail', args=[case['notice'].public_id])
client.force_login(case['creator'])
key = str(uuid.uuid4())
assert client.get(endpoint).status_code == 405
assert client.post(endpoint, {'request_key': key}).status_code == 302
retry = PaymentNotification.objects.filter(notice=case['notice']).exclude(pk=original.pk).get()
assert retry.job.status == 'PENDING'
client.post(endpoint, {'request_key': key})
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
client.post(endpoint, {'request_key': str(uuid.uuid4())})
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
call_command('process_notifications', once=True, job_id=retry.job_id)
retry.refresh_from_db(); retry.job.refresh_from_db()
assert retry.job.status == 'SENT' and retry.job.delivery_status == 'PENDING'
assert original.access_link_id != retry.access_link_id
assert retry.context['payment_link'] in sent[-1][1]
awaiting = client.get(detail).content.decode()
assert 'پذیرفته‌شده توسط سرویس پیامک' in awaiting and 'در انتظار استعلام دریافت توسط گوشی' in awaiting
NotificationJob.objects.filter(pk=retry.job_id).update(delivery_next_check_at=timezone.now())
call_command('process_notifications', once=True, job_id=retry.job_id)
completed = client.get(detail).content.decode()
assert all(value in completed for value in ['رسیده به گوشی', 'پیامک به گوشی رسید', 'new-sms-id', 'اعتبار پنل کافی نیست', 'آخرین استعلام'])
assert original.attempt_log.count() == 1 and retry.attempt_log.count() == 1 and len(sent) == 2
assert PaymentAuditEvent.objects.filter(action='NOTICE_SMS_RESEND_REQUESTED').count() == 1
# Replaying the same form after completion must still not issue another message.
client.post(endpoint, {'request_key': key})
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
@pytest.mark.parametrize('status,final,next_check,text', [
('UNDELIVERED', False, True, 'نتیجه غیرقطعی'),
('UNDELIVERED', True, False, 'نرسیده به گوشی'),
('UNKNOWN', False, False, 'دریافت توسط گوشی تأیید نشده است'),
('CHECK_FAILED', False, True, 'خطای موقت استعلام'),
('EXPIRED', True, False, 'منقضی‌شده'),
])
def test_sms_history_distinguishes_final_and_provisional_delivery(case, client, status, final, next_check, text):
notification = PaymentNotification.objects.get(notice=case['notice'])
NotificationJob.objects.filter(pk=notification.job_id).update(status='SENT', provider_message_id='tracked-id',
delivery_status=status, delivery_is_final=final, delivery_status_code='6',
delivery_next_check_at=timezone.now() if next_check else None)
client.force_login(case['creator'])
html = client.get(reverse('staff_payments:notice_detail', args=[case['notice'].public_id])).content.decode()
assert text in html
assert ('نتیجه غیرقطعی' in html) is not final
def test_sms_resend_permission_invalid_key_and_csrf(case, client):
notice = case['notice']
notification = PaymentNotification.objects.get(notice=notice)
NotificationJob.objects.filter(pk=notification.job_id).update(status='FAILED')
endpoint = reverse('staff_payments:notice_resend_sms', args=[notice.public_id])
secure = Client(enforce_csrf_checks=True)
secure.force_login(case['creator'])
assert secure.post(endpoint, {'request_key': str(uuid.uuid4())}).status_code == 403
client.force_login(case['creator'])
client.post(endpoint, {'request_key': 'invalid'})
assert PaymentNotification.objects.filter(notice=notice).count() == 1
PaymentAccessConfiguration.objects.update_or_create(pk=1, defaults={'open_create': False, 'open_view': False})
with pytest.raises(PermissionDenied):
services.resend_notice_sms(case['creator'], notice, str(uuid.uuid4()))
grant(case['outsider'], 'add_paymentnotice')
with pytest.raises(PermissionDenied):
services.resend_notice_sms(case['outsider'], notice, str(uuid.uuid4()))
with pytest.raises(PermissionDenied):
services.resend_notice_sms(case['customer'], notice, str(uuid.uuid4()))
@pytest.mark.parametrize('reason', ['cancelled', 'inactive', 'deleted'])
def test_invalid_notice_cannot_be_resent(case, reason):
notice = case['notice']
if reason == 'inactive':
case['customer'].is_active = False; case['customer'].save(update_fields=['is_active'])
else:
PaymentNotice.objects.filter(pk=notice.pk).update(**{'cancelled_at' if reason == 'cancelled' else 'deleted_at': timezone.now()})
with pytest.raises((ValidationError, PermissionDenied)):
services.resend_notice_sms(case['creator'], notice, str(uuid.uuid4()))
assert PaymentNotification.objects.filter(notice=notice).count() == 1