fix payment
This commit is contained in:
1 parent
4f7486152c
commit
bb3ddd1610
47 files changed
+2137
-51
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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 'فیش ثبت شد و در انتظار بررسی است.')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
@@ -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
|
||||
Reference in new issue
Block a user