add payment_link and banks

This commit is contained in:
javad committed 2026-10-06 10:03:22 +03:30
1 parent 2ae79b1ca4
commit de3135b588
100 files changed
+6167 -47

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ class AuthenticationPage(BasePage):
self.page.locator('input[name="mobile"]').fill(customer.local_mobile)
self.page.locator('input[name="password"]').fill(password)
self.page.get_by_role("button", name="ورود به حساب").click()
expect(self.page).to_have_url(self.url(reverse("tickets:list")))
expect(self.page).to_have_url(self.url(reverse("payments:home")))
def login_staff(self, staff, password):
self.goto(reverse("accounts:staff_login"))
+44
View File
@@ -0,0 +1,44 @@
from django.urls import reverse
from playwright.sync_api import expect
from tests.e2e.pages.base import BasePage
class MagicLinkConfirmationPage(BasePage):
def open(self, token):
self.goto(reverse("accounts:magic", args=[token]))
expect(self.page.get_by_role("heading", name="این لینک مربوط به حساب دیگری است")).to_be_visible()
@property
def continue_button(self):
return self.page.get_by_role("button", name="خروج از حساب و مشاهده تیکت", exact=True)
@property
def cancel_link(self):
return self.page.get_by_role("link", name="انصراف از مشاهده این تیکت/پیام", exact=True)
def continue_to_ticket(self, ticket):
self.continue_button.click()
expect(self.page).to_have_url(self.url(reverse("tickets:detail", args=[ticket.public_id])))
expect(self.page.get_by_role("heading", name=ticket.subject, exact=True)).to_be_visible()
expect(self.page.get_by_text("فقط به همین تیکت دسترسی دارید", exact=False)).to_be_visible()
def cancel(self):
self.cancel_link.click()
expect(self.page).to_have_url(self.url(reverse("payments:home")))
def expect_readable_layout(self):
expect(self.continue_button).to_be_visible()
expect(self.cancel_link).to_be_visible()
metrics = self.page.evaluate("""() => ({
width: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
textSize: parseFloat(getComputedStyle(document.querySelector('#confirmation-description')).fontSize),
background: getComputedStyle(document.querySelector('.magic-confirmation-primary')).backgroundColor,
color: getComputedStyle(document.querySelector('.magic-confirmation-primary')).color,
})""")
assert metrics["scrollWidth"] <= metrics["width"]
assert metrics["textSize"] >= 16
assert metrics["background"] == "rgb(8, 55, 107)" and metrics["color"] == "rgb(255, 255, 255)"
for control in (self.continue_button, self.cancel_link):
assert control.bounding_box()["height"] >= 44
+40
View File
@@ -0,0 +1,40 @@
from playwright.sync_api import expect
from .payments import PaymentsPage
class PaymentLinkPage(PaymentsPage):
def open_link(self, path):
self.goto(path)
expect(self.page.get_by_role('heading', name='جزئیات واریز', exact=True)).to_be_visible()
def copy_all(self):
self.page.get_by_role('button', name='کپی کامل اطلاعات', exact=True).click()
expect(self.page.get_by_text('کپی شد', exact=True).or_(
self.page.get_by_role('heading', name='متن را انتخاب و کپی کنید'))).to_be_visible()
if self.page.get_by_role('dialog').is_visible():
self.page.get_by_role('button', name='بستن', exact=True).click()
def open_receipt_form(self, linked):
if linked:
self.page.get_by_role('link', name='ارسال فیش این واریز', exact=True).click()
else:
self.page.get_by_role('link', name='ارسال فیش واریز مستقل', exact=True).click()
expect(self.page.get_by_role('heading', name='ارسال فیش واریزی', exact=True)).to_be_visible()
def show_required_errors(self):
self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click()
expect(self.page.locator('#receipt-errors')).to_be_visible()
expect(self.page.locator('#id_paid_amount')).to_be_focused()
def submit_payment(self, png, account_id=None):
if account_id is not None:
self.page.locator('#id_destination_account').select_option(str(account_id))
self.page.get_by_label('شناسه واریز که با آن پرداخت انجام داده‌اید (اختیاری)', exact=True).fill('')
expect(self.page.locator('#id_deposit_id')).not_to_have_attribute('required', '')
self.page.locator('#id_paid_amount').fill('۱۲۳۴۵')
expect(self.page.locator('#id_paid_amount')).to_have_value('۱۲٬۳۴۵')
self.page.get_by_role('button', name='امروز', exact=True).click()
self.page.locator('#id_receipt_image').set_input_files({'name': 'payment.png', 'mimeType': 'image/png', 'buffer': png})
self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click()
expect(self.page.locator('.flash')).to_contain_text('فیش ثبت شد و در انتظار بررسی است.')
+33
View File
@@ -0,0 +1,33 @@
from django.urls import reverse
from playwright.sync_api import expect
from .base import BasePage
class PaymentsPage(BasePage):
def select_customer(self, mobile):
self.page.get_by_label('جست‌وجو و انتخاب مشتری', exact=False).fill(mobile)
result = self.page.locator('.pay-customer-result').filter(has_text=mobile)
result.get_by_role('button', name='انتخاب این مشتری', exact=True).click()
expect(self.page.locator('[data-selected-customer]')).to_contain_text(mobile)
def open_hub(self):
self.goto(reverse('payments:home'))
expect(self.page.get_by_role('heading',name='چهارسو',exact=True)).to_be_visible()
def send_receipt(self, account_id, png, linked=False):
self.goto(reverse('payments:receipt_new'))
if not linked:
self.page.get_by_label('واریز مستقل',exact=True).check()
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})
self.page.get_by_role('button',name='ثبت و ارسال فیش').click()
expect(self.page.locator('.flash')).to_contain_text('فیش ثبت شد و در انتظار بررسی است.')
def approve(self, receipt_id, senior=False):
self.goto(reverse('staff_payments:receipt_detail',args=[receipt_id]))
if senior:
self.page.locator('#decision-reason').fill('تطبیق پرداخت بررسی شد')
self.page.get_by_role('button',name='تأیید نهایی ارشد' if senior else 'ثبت تأیید من',exact=True).click()
expect(self.page.locator('.flash')).to_contain_text('تصمیم ثبت شد.')
+26
View File
@@ -0,0 +1,26 @@
from playwright.sync_api import expect
from .base import BasePage
class TicketNoticesPage(BasePage):
def open_customer_panel(self):
if self.page.get_by_role('button', name='اطلاعات مشتری', exact=True).is_visible():
self.page.get_by_role('button', name='اطلاعات مشتری', exact=True).click()
expect(self.page.locator('#customer-info')).to_be_in_viewport()
def toggle_notices(self):
self.page.locator('.ticket-notices > summary').click()
def open_notice(self, subject):
self.page.get_by_role('link').filter(has=self.page.get_by_text(subject, exact=True)).click()
def open_all(self):
self.page.get_by_role('link', name='بیشتر همه اعلامیه‌های این مشتری').click()
def expect_closed(self):
expect(self.page.get_by_role('link', name='ایجاد اعلامیه واریز', exact=True)).to_be_hidden()
def expect_open(self):
expect(self.page.get_by_role('link', name='ایجاد اعلامیه واریز', exact=True)).to_be_visible()
expect(self.page.get_by_role('list', name='اعلامیه‌های اخیر مشتری').get_by_role('link')).to_have_count(3)
+1 -1
View File
@@ -26,7 +26,7 @@ def test_external_customer_login_and_public_routing_are_usable(
page.locator('input[name="mobile"]').fill(mobile)
page.locator('input[name="password"]').fill(password)
page.get_by_role("button", name="ورود به حساب").click()
expect(page).to_have_url(f"{application_base_url}/tickets/")
expect(page).to_have_url(f"{application_base_url}/chaharsu/")
page.goto(f"{application_base_url}/tickets/new/", wait_until="domcontentloaded")
expect(page.locator("[data-ticket-unit]")).to_be_visible()
expect(page.locator("[data-ticket-category]")).to_be_visible()
@@ -0,0 +1,75 @@
from pathlib import Path
import pytest
from django.urls import reverse
from playwright.sync_api import expect
from apps.accounts.models import TicketMagicLink, User
from apps.customers.models import CustomerProfile
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.magic_link import MagicLinkConfirmationPage
from tests.e2e.pages.tickets import TicketConversationPage
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
PASSWORD = "Magic-link-test-password-2026"
MESSAGE = "شما با حساب دیگری وارد سامانه شده‌اید. برای مشاهده تیکت مرتبط با این لینک، از حساب فعلی خارج شوید. پس از خروج، تیکت به‌صورت خودکار باز می‌شود و نیازی به ورود مجدد ندارید."
@pytest.fixture
def browser_magic_case():
category = TicketCategory.objects.create(title="پشتیبانی لینک", slug="browser-magic")
owner = User.objects.create_user(username="browser-link-owner", mobile="09123000101", account_type="CUSTOMER")
other = User.objects.create_user(username="browser-link-other", mobile="09123000102", account_type="CUSTOMER", password=PASSWORD)
CustomerProfile.objects.create(user=owner)
CustomerProfile.objects.create(user=other)
ticket = create_ticket(customer=owner, category=category, subject="درخواست مرتبط با لینک", body="محتوای تیکت مرتبط")
token = TicketMagicLink.issue(ticket)
return other, ticket, token
@pytest.mark.parametrize("width", [320, 390, 1280])
def test_confirmed_logout_opens_ticket_with_readable_responsive_page(browser_page, live_server, browser_magic_case, width):
other, ticket, token = browser_magic_case
with browser_page() as page:
page.set_viewport_size({"width": width, "height": 844 if width < 640 else 900})
AuthenticationPage(page, live_server.url).login_customer(other, PASSWORD)
confirmation = MagicLinkConfirmationPage(page, live_server.url)
confirmation.open(token)
expect(page.locator("#confirmation-description")).to_have_text(MESSAGE)
expect(page.get_by_text("محتوای تیکت مرتبط", exact=True)).to_have_count(0)
confirmation.expect_readable_layout()
confirmation.continue_button.focus()
expect(confirmation.continue_button).to_be_focused()
assert confirmation.continue_button.evaluate("node => getComputedStyle(node).outlineStyle") == "solid"
artifact = Path("test-results/e2e") / f"magic-confirmation-{width}.png"
artifact.parent.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(artifact), full_page=True)
confirmation.continue_to_ticket(ticket)
TicketConversationPage(page, live_server.url).send_reply("پاسخ مرورگر پس از خروج")
confirmation.goto(reverse("accounts:profile"))
expect(page).to_have_url(confirmation.url(reverse("accounts:login")) + "?next=" + reverse("accounts:profile"))
def test_cancel_returns_to_logged_in_account(browser_page, live_server, browser_magic_case):
other, ticket, token = browser_magic_case
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_customer(other, PASSWORD)
confirmation = MagicLinkConfirmationPage(page, live_server.url)
confirmation.open(token)
confirmation.cancel()
confirmation.goto(reverse("accounts:profile"))
expect(page.get_by_role("heading", name="پروفایل من")).to_be_visible()
expect(page.locator("main")).to_contain_text(other.mobile)
def test_confirmation_form_works_without_its_javascript(browser_page, live_server, browser_magic_case):
other, ticket, token = browser_magic_case
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_customer(other, PASSWORD)
page.route("**/magic-link-confirmation.js*", lambda route: route.abort())
confirmation = MagicLinkConfirmationPage(page, live_server.url)
confirmation.open(token)
confirmation.continue_to_ticket(ticket)
+41
View File
@@ -0,0 +1,41 @@
from urllib.parse import urlsplit
import pytest
from playwright.sync_api import expect
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.test_payments_e2e import payment_case, PNG
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@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
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
'subject': 'اعلامیه بدون ورود', 'amount': 12345, 'deposit_id': '000123', 'description': 'اطلاعات کامل اعلامیه'})
_, url = issue_payment_link(notice=notice)
branding = SiteBranding.load()
branding.developer_credit_text = 'امضای ثابت توسعه‌دهنده'
branding.save()
with browser_page() as page:
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.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)
assert page.locator('.pay-deposit-required dd').evaluate('(element) => getComputedStyle(element).color') == 'rgb(159, 31, 31)'
view.copy_all()
view.open_receipt_form(linked)
view.show_required_errors()
view.submit_payment(PNG, account_id=account.pk if not linked else None)
expect(page.get_by_text('در انتظار بررسی', exact=False).first).to_be_visible()
expect(page.get_by_role('button', name='ثبت تأیید من', exact=True)).to_have_count(0)
if not linked:
expect(page.get_by_text('واریز مستقل', exact=True)).to_be_visible()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
+361
View File
@@ -0,0 +1,361 @@
import base64
import pytest
from django.contrib.auth.models import Permission
from django.urls import reverse
from playwright.sync_api import expect
from apps.accounts.models import User
from apps.staff.models import StaffProfile
from apps.attachments.models import AttachmentPolicy
from apps.payments.models import BankInstitution, CompanyBankAccount, PaymentApprovalPolicy
from apps.payments import services
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.payments import PaymentsPage
pytestmark=[pytest.mark.e2e,pytest.mark.django_db(transaction=True)]
PASSWORD='PaymentE2e-safe-2026'
PNG=base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=')
@pytest.fixture
def payment_case(settings,tmp_path):
settings.MEDIA_ROOT=tmp_path
bank,_=BankInstitution.objects.get_or_create(code='melli',defaults={'name':'ملی'})
account=CompanyBankAccount.objects.create(name='حساب فروش شرکت',holder='شرکت',bank=bank,account_number='000123')
customer=User.objects.create_user(username='payment-e2e-customer',mobile='09120009021',password=PASSWORD,account_type='CUSTOMER')
reviewers=[]
for index in range(2):
user=User.objects.create_user(username=f'payment-e2e-reviewer-{index}',password=PASSWORD,account_type='STAFF')
StaffProfile.objects.create(user=user,display_name=f'تأییدکننده {index}')
user.user_permissions.add(Permission.objects.get(content_type__app_label='payments',codename='approve_paymentreceipt'))
reviewers.append(user)
policy=PaymentApprovalPolicy.objects.create(name='تأیید هر دو نفر')
policy.reviewers.add(*reviewers)
AttachmentPolicy.objects.update_or_create(attachment_type='IMAGE',defaults={'max_file_size':10*1024*1024,'allowed_extensions':['.png'],'allowed_mime_types':['image/png']})
return customer,account,reviewers
def test_customer_hub_no_history_and_small_mobile_services(live_server,browser_page,payment_case):
customer,account,reviewers=payment_case
with browser_page() as page:
page.set_viewport_size({'width':320,'height':568})
AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD)
PaymentsPage(page,live_server.url).open_hub()
expect(page.get_by_role('heading',name='درخواست‌های اخیر شما')).to_have_count(0)
for name in ['ارتباط با کارشناسان','ارسال فیش واریزی','شماره حساب‌ها']:
expect(page.get_by_role('heading',name=name,exact=True)).to_be_in_viewport()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
page.get_by_role('link',name='ثبت درخواست',exact=True).click()
expect(page).to_have_url(live_server.url+reverse('tickets:new'))
def test_independent_receipt_and_two_person_approval(live_server,browser_page,payment_case):
customer,account,reviewers=payment_case
with browser_page() as page:
AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD)
PaymentsPage(page,live_server.url).send_receipt(account.pk,PNG)
expect(page.get_by_text('واریز مستقل',exact=True)).to_be_visible()
# Read the public identifier from real navigation; no ORM inside Playwright's loop.
receipt_id=page.url.rstrip('/').rsplit('/',1)[-1]
page.goto(live_server.url+reverse('accounts:staff_login'))
# Explicit logout using the real page control preserves session/CSRF behavior.
page.get_by_role('button',name='خروج',exact=True).first.click()
AuthenticationPage(page,live_server.url).login_staff(reviewers[0],PASSWORD)
PaymentsPage(page,live_server.url).approve(receipt_id)
expect(page.get_by_text('تأیید ناقص',exact=True)).to_be_visible()
page.get_by_role('button',name='خروج',exact=True).first.click()
AuthenticationPage(page,live_server.url).login_staff(reviewers[1],PASSWORD)
PaymentsPage(page,live_server.url).approve(receipt_id)
expect(page.get_by_text('تأیید شده',exact=True)).to_be_visible()
expect(page.get_by_role('button',name='ثبت تأیید من')).to_have_count(0)
def test_form_errors_focus_and_full_copy(live_server,browser_page,payment_case):
customer,account,reviewers=payment_case
notice=services.issue_notice(reviewers[0],{'customer':customer,'account':account,'amount':12345,'subject':'فاکتور نمونه','description':'توضیح ثابت و کامل','deposit_id':'00099'})
with browser_page() as page:
AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD)
page.goto(live_server.url+reverse('payments:receipt_new'))
page.get_by_role('button',name='ثبت و ارسال فیش').click()
expect(page.locator('#receipt-errors')).to_be_visible()
expect(page.locator('#id_paid_amount')).to_be_focused()
page.goto(live_server.url+reverse('payments:notices')+'?notice='+str(notice.public_id))
expect(page.get_by_text('توضیح ثابت و کامل',exact=True)).to_be_visible()
page.get_by_role('button',name='کپی کامل اطلاعات').click()
# Browser may deny Clipboard on LAN HTTP; accessible manual fallback is valid.
expect(page.get_by_text('کپی شد',exact=True).or_(page.get_by_role('heading',name='متن را انتخاب و کپی کنید'))).to_be_visible()
def test_amount_grouping_and_jalali_leap_calendar(live_server,browser_page,payment_case):
customer,account,reviewers=payment_case
with browser_page() as page:
assets=[]
page.on('request',lambda r:assets.append(r.url) if r.resource_type in {'script','stylesheet','font','image'} else None)
AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD)
page.goto(live_server.url+reverse('payments:receipt_new'))
amount=page.locator('#id_paid_amount')
amount.fill('12345')
expect(amount).to_have_value('۱۲٬۳۴۵')
expect(page.locator('#amount-words')).to_contain_text('دهم تومان')
amount.fill('12.5')
expect(amount).to_have_value('12.5')
page.get_by_role('button',name='ثبت و ارسال فیش').click()
expect(amount).to_be_focused()
expect(page.locator('#error-paid_amount')).to_contain_text('عدد صحیح')
page.locator('#id_paid_year').select_option('1403')
page.locator('#id_paid_month').select_option('12')
page.locator('#id_paid_day').select_option('30')
expect(page.locator('#paid-date-iso')).to_have_value('2025-03-20')
page.locator('#id_paid_year').select_option('1402')
expect(page.locator('#id_paid_day')).to_have_value('')
expect(page.locator('#paid-date-iso')).to_have_value('')
assert assets and all(url.startswith(live_server.url+'/') or url.startswith('data:') for url in assets)
def test_staff_issues_identifier_notice_and_customer_sends_linked_receipt(live_server,browser_page,payment_case):
customer,account,reviewers=payment_case
account.requires_deposit_id=True;account.save()
with browser_page() as page:
AuthenticationPage(page,live_server.url).login_staff(reviewers[0],PASSWORD)
page.goto(live_server.url+reverse('staff_payments:desk'))
page.get_by_role('link',name='ایجاد اعلامیه واریز',exact=True).click()
PaymentsPage(page,live_server.url).select_customer(customer.local_mobile)
page.locator('#id_account').select_option(str(account.pk))
page.locator('#id_subject').fill('پرداخت فاکتور اختصاصی')
page.locator('#id_amount').fill('100000')
expect(page.locator('#id_amount')).to_have_value('۱۰۰٬۰۰۰')
expect(page.locator('#id_deposit_id')).to_have_attribute('required','')
page.locator('#id_deposit_id').fill('۰۰۱۲۳')
page.locator('#id_description').fill('شناسه فقط برای همین واریز')
page.get_by_role('button',name='صدور و اطلاع‌رسانی').click()
expect(page.locator('.flash')).to_contain_text('اعلامیه صادر شد')
notice_id=page.url.rstrip('/').rsplit('/',1)[-1]
page.get_by_role('button',name='خروج',exact=True).first.click()
AuthenticationPage(page,live_server.url).login_customer(customer,PASSWORD)
page.goto(live_server.url+reverse('payments:notices')+'?notice='+notice_id)
expect(page.get_by_text('شناسه فقط برای همین واریز',exact=True)).to_be_visible()
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')
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})
page.get_by_role('button',name='ثبت و ارسال فیش').click()
expect(page.locator('.flash')).to_contain_text('فیش ثبت شد')
expect(page.get_by_text('واریز مستقل',exact=True)).to_have_count(0)
page.goto(live_server.url+reverse('payments:receipt_list'))
expect(page.locator('article.pay-card').filter(has_text='پرداخت فاکتور اختصاصی')).to_contain_text('در انتظار بررسی')
def test_staff_payment_filters_and_bank_selection(live_server, browser_page, payment_case):
customer, account, reviewers = payment_case
reviewers[0].user_permissions.add(Permission.objects.get(
content_type__app_label='payments', codename='manage_company_bankaccounts'))
CompanyBankAccount.objects.create(name='حساب دوم شرکت', holder='شرکت', bank=account.bank,
account_number='000456', is_active=False)
services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
'amount': 100000, 'subject': 'پرداخت نمونه برای بررسی میز مالی', 'description': '', 'deposit_id': ''})
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
page.goto(live_server.url + reverse('staff_payments:desk'))
page.get_by_role('link', name='اعلامیه‌های واریز', exact=True).click()
expect(page.get_by_role('link', name='پرداخت نمونه برای بررسی میز مالی')).to_be_visible()
page.get_by_role('link', name='نیازمند اقدام من', exact=True).click()
page.get_by_role('button', name='جست‌وجو', exact=True).click()
expect(page.get_by_role('link', name='نیازمند اقدام من', exact=True)).to_have_attribute('aria-current', 'page')
expect(page.get_by_text('در حال حاضر فیشی برای اقدام شما وجود ندارد.', exact=True)).to_be_visible()
page.goto(live_server.url + reverse('staff_payments:banks'))
page.set_viewport_size({'width': 375, 'height': 812})
submit = page.get_by_role('button', name='اعمال بر انتخاب‌شده‌ها', exact=True)
expect(submit).to_be_disabled()
page.get_by_role('checkbox', name='انتخاب همه حساب‌های این صفحه', exact=True).check()
expect(submit).to_be_enabled()
expect(page.get_by_text('۲ حساب انتخاب شده', exact=True)).to_be_visible()
page.get_by_role('checkbox', name='انتخاب حساب دوم شرکت', exact=True).uncheck()
expect(page.get_by_text('۱ حساب انتخاب شده', exact=True)).to_be_visible()
expect(page.get_by_role('link', name='ویرایش', exact=True)).to_have_count(2)
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
def test_staff_bank_forms_and_receipt_customer_steps(live_server, browser_page, payment_case):
customer, account, reviewers = payment_case
reviewers[0].user_permissions.add(Permission.objects.get(
content_type__app_label='payments', codename='manage_company_bankaccounts'))
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
page.set_viewport_size({'width': 375, 'height': 812})
page.goto(live_server.url + reverse('staff_payments:bank_new'))
page.get_by_label('نام نمایشی حساب', exact=False).fill('حساب طراحی فرم')
page.locator('#id_bank').select_option(str(account.bank_id))
page.locator('#id_holder').fill('شرکت نمونه')
page.locator('#id_account_number').fill('123456789')
page.get_by_role('checkbox', name='شناسه واریز الزامی است', exact=False).check()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
page.get_by_role('button', name='ذخیره حساب', exact=True).click()
page.get_by_role('link', name='حساب طراحی فرم', exact=True).click()
expect(page.get_by_role('checkbox', name='شناسه واریز الزامی است', exact=False)).to_be_checked()
page.get_by_role('checkbox', name='قابل انتخاب کارشناسان', exact=False).uncheck()
page.get_by_role('button', name='ذخیره حساب', exact=True).click()
expect(page.locator('.pay-bank-row').filter(has_text='حساب طراحی فرم').get_by_text('غیرفعال', exact=True)).to_be_visible()
page.goto(live_server.url + reverse('staff_payments:receipt_customer'))
PaymentsPage(page,live_server.url).select_customer(customer.local_mobile)
page.get_by_role('button', name='ادامه', exact=True).click()
expect(page.get_by_role('heading', name='ثبت فیش برای مشتری', exact=True)).to_be_visible()
expect(page.locator('#id_paid_amount')).to_be_visible()
page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click()
expect(page.locator('#receipt-errors')).to_be_visible()
expect(page.locator('#id_paid_amount')).to_be_focused()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
def test_customer_search_disambiguation_and_context_shortcuts(live_server, browser_page, payment_case):
from tests.e2e.pages.ticket_notices import TicketNoticesPage
from apps.customers.models import CustomerProfile
from apps.tickets.models import Ticket, TicketCategory
from django.utils import timezone
customer, account, reviewers = payment_case
CustomerProfile.objects.create(user=customer, first_name='محمد', last_name='رضایی', organization_name='شرکت اول')
other = User.objects.create_user(username='other-payment-customer', mobile='09120009022', account_type='CUSTOMER')
CustomerProfile.objects.create(user=other, first_name='محمد', last_name='رضایی', organization_name='شرکت دوم')
ticket = Ticket.objects.create(customer=customer,
category=TicketCategory.objects.create(title='پرونده مالی', slug='finance-picker-e2e'),
subject='سابقه خرید برای انتخاب دقیق', owner=reviewers[0], selected_expert=reviewers[0],
origin_visibility='SELECTED', last_activity_at=timezone.now())
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
page.goto(live_server.url + reverse('staff_payments:notice_new'))
page.get_by_label('جست‌وجو و انتخاب مشتری', exact=False).fill('محمد رضایی')
expect(page.locator('.pay-customer-result')).to_have_count(2)
result = page.locator('.pay-customer-result').filter(has_text=customer.local_mobile)
result.locator('summary').click()
expect(result.get_by_role('link', name=ticket.ticket_number + ' · ' + ticket.subject)).to_be_visible()
result.get_by_role('button', name='انتخاب این مشتری', exact=True).click()
expect(page.locator('[data-selected-customer]')).to_contain_text('شرکت اول')
expect(page.locator('[data-selected-customer]')).to_contain_text(customer.local_mobile)
page.locator('#id_account').select_option(str(account.pk))
page.locator('#id_subject').fill('انتخاب مشتری باید دوباره انجام شود')
page.locator('#id_amount').fill('100000')
page.get_by_label('جست‌وجو و انتخاب مشتری', exact=False).fill('0912')
page.get_by_role('button', name='صدور و اطلاع‌رسانی', exact=True).click()
expect(page.get_by_label('جست‌وجو و انتخاب مشتری', exact=False)).to_be_focused()
page.goto(live_server.url + reverse('staff_tickets:detail', args=[ticket.public_id]))
TicketNoticesPage(page, live_server.url).toggle_notices()
page.locator('#customer-info').get_by_role('link', name='ایجاد اعلامیه واریز', exact=True).click()
expect(page).to_have_url(live_server.url + reverse('staff_payments:notice_new') + '?customer=' + str(customer.public_id))
expect(page.locator('[data-selected-customer]')).to_contain_text(customer.local_mobile)
page.goto(live_server.url + reverse('staff_tickets:contacts'))
page.locator('.contact-card').filter(has_text=customer.local_mobile).get_by_role('link', name='ایجاد اعلامیه واریز', exact=True).click()
expect(page.locator('[data-selected-customer]')).to_contain_text(customer.local_mobile)
def test_customer_finance_preview_and_notice_context(live_server, browser_page, payment_case):
customer, account, reviewers = payment_case
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
'amount': 120000, 'subject': 'واریز مرتبط نمونه', 'description': 'توضیحات همیشه نمایان',
'invoice_number': 'INV-120', 'deposit_id': '00055'})
with browser_page() as page:
page.set_viewport_size({'width': 320, 'height': 568})
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
page.goto(live_server.url + reverse('payments:notices') + '?notice=' + str(notice.public_id))
expect(page.get_by_text('توضیحات همیشه نمایان', exact=True)).to_be_visible()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
page.get_by_role('link', name='ارسال فیش این واریز').click()
expect(page.locator('[data-selected-notice-title]')).to_have_text(notice.subject)
expect(page.locator('[data-selected-notice-summary]')).to_contain_text('INV-120')
expect(page.locator('[data-selected-notice-link]')).to_have_attribute('href', reverse('payments:notice_detail', args=[notice.public_id]))
page.locator('#id_receipt_image').set_input_files({'name': 'receipt.png', 'mimeType': 'image/png', 'buffer': PNG})
expect(page.get_by_alt_text('پیش‌نمایش تصویر فیش')).to_be_visible()
expect(page.locator('#receipt-file-status')).to_contain_text('receipt.png')
page.get_by_role('button', name='حذف انتخاب').click()
expect(page.get_by_alt_text('پیش‌نمایش تصویر فیش')).to_be_hidden()
page.locator('#id_paid_amount').fill('120000')
page.get_by_role('button', name='امروز', exact=True).click()
page.get_by_role('button', name='ثبت و ارسال فیش').click()
expect(page.locator('#error-receipt_image')).to_be_visible()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
def test_customer_linked_receipt_status_and_reason_without_access_roster(live_server, browser_page, payment_case):
customer, account, reviewers = payment_case
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
'amount': 12345, 'subject': 'بررسی فیش مرتبط', 'description': 'جزئیات واریز'})
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
page.goto(live_server.url + reverse('payments:receipt_new') + '?notice=' + str(notice.public_id))
page.locator('#id_paid_amount').fill('12345')
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})
page.get_by_role('button', name='ثبت و ارسال فیش').click()
expect(page.locator('.flash')).to_contain_text('فیش ثبت شد')
receipt_id = page.url.rstrip('/').rsplit('/', 1)[-1]
page.get_by_role('button', name='خروج', exact=True).first.click()
AuthenticationPage(page, live_server.url).login_staff(reviewers[1], PASSWORD)
page.goto(live_server.url + reverse('staff_payments:receipt_detail', args=[receipt_id]))
expect(page.get_by_role('button', name='ثبت تأیید من', exact=True)).to_be_visible()
expect(page.get_by_role('heading', name='افراد تعیین‌شده', exact=True)).to_have_count(0)
page.locator('#decision-reason').fill('تصویر فیش خوانا نیست')
page.get_by_role('button', name='رد فیش', exact=True).click()
expect(page.get_by_text('تصویر فیش خوانا نیست', exact=True)).to_be_visible()
page.get_by_role('button', name='خروج', exact=True).first.click()
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
page.goto(live_server.url + reverse('payments:notices') + '?notice=' + str(notice.public_id))
expect(page.get_by_text('تصویر فیش خوانا نیست', exact=True)).to_be_visible()
page.get_by_role('link', name='جزئیات فیش و سوابق بررسی', exact=True).click()
expect(page.get_by_text('تصویر فیش خوانا نیست', exact=True)).to_be_visible()
expect(page.get_by_role('heading', name='افراد تعیین‌شده', exact=True)).to_have_count(0)
expect(page.get_by_role('heading', name='سوابق ارسال و اصلاح', exact=True)).to_be_visible()
def test_notice_permalink_and_staff_receipt_preview(live_server, browser_page, payment_case):
customer, account, reviewers = payment_case
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
'amount': 12000, 'subject': 'واریز با لینک مستقل', 'description': 'اطلاعات واریز'})
with browser_page() as page:
page.set_viewport_size({'width': 375, 'height': 812})
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
page.goto(live_server.url + reverse('payments:notices'))
page.get_by_role('link', name='جزئیات واریز و فیش‌ها', exact=True).click()
expect(page).to_have_url(live_server.url + reverse('payments:notice_detail', args=[notice.public_id]))
expect(page.get_by_text('اطلاعات واریز', exact=True)).to_be_visible()
page.get_by_role('link', name='ارسال فیش این واریز', exact=True).click()
page.locator('[data-selected-notice-link]').click()
expect(page).to_have_url(live_server.url + reverse('payments:notice_detail', args=[notice.public_id]))
page.get_by_role('button', name='خروج', exact=True).first.click()
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
page.goto(live_server.url + reverse('staff_payments:receipt_new', args=[customer.public_id]) + '?notice=' + str(notice.public_id))
expect(page.get_by_role('heading', name='ثبت فیش برای مشتری', exact=True)).to_be_visible()
expect(page.locator('[data-selected-notice-link]')).to_have_attribute('href', reverse('staff_payments:notice_detail', args=[notice.public_id]))
page.locator('#id_paid_amount').fill('12000')
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})
expect(page.get_by_alt_text('پیش‌نمایش تصویر فیش')).to_be_visible()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click()
expect(page.locator('.flash')).to_contain_text('فیش ثبت شد')
def test_notice_identifier_generation_preserves_manual_value_and_customer_context(live_server, browser_page, payment_case):
customer, account, reviewers = payment_case
account.requires_deposit_id = True
account.save()
with browser_page() as page:
page.set_viewport_size({'width': 375, 'height': 812})
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
page.goto(live_server.url + reverse('staff_payments:notice_new'))
generate = page.get_by_role('button', name='تولید خودکار شناسه', exact=True)
expect(generate).to_be_disabled()
PaymentsPage(page, live_server.url).select_customer(customer.local_mobile)
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')
page.locator('#id_deposit_id').fill('manual-123')
generate.click()
expect(page.locator('#identifier-candidate')).to_have_text('0912000902169')
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')
page.get_by_role('button', name='تغییر مشتری', exact=True).click()
expect(page.locator('#id_deposit_id')).to_have_value('')
expect(generate).to_be_disabled()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
+53
View File
@@ -0,0 +1,53 @@
import pytest
from django.urls import reverse
from django.utils import timezone
from playwright.sync_api import expect
from apps.accounts.models import User
from apps.payments import services
from apps.payments.models import BankInstitution, CompanyBankAccount
from apps.staff.models import StaffProfile
from apps.tickets.models import Ticket, TicketCategory
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.ticket_notices import TicketNoticesPage
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize('width', [1440, 390])
def test_ticket_notices_toggle_and_customer_navigation(live_server, browser_page, width):
password = 'TicketNotices-e2e-2026'
staff = User.objects.create_user(username='notice-staff', password=password, account_type='STAFF')
StaffProfile.objects.create(user=staff, display_name='کارشناس اعلامیه')
customer = User.objects.create_user(username='notice-customer', account_type='CUSTOMER')
bank, _ = BankInstitution.objects.get_or_create(code='melli', defaults={'name': 'ملی'})
account = CompanyBankAccount.objects.create(name='حساب اعلامیه', holder='شرکت', bank=bank, account_number='123')
notices = [services.issue_notice(staff, {'customer': customer, 'account': account,
'subject': f'اعلامیه نمونه {index}', 'amount': 12345}) for index in range(4)]
ticket = Ticket.objects.create(customer=customer,
category=TicketCategory.objects.create(title='مالی', slug='ticket-notices'),
subject='تیکت اعلامیه', owner=staff, selected_expert=staff,
origin_visibility='SELECTED', last_activity_at=timezone.now())
ticket_url = reverse('staff_tickets:detail', args=[ticket.public_id])
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
AuthenticationPage(page, live_server.url).login_staff(staff, password)
view = TicketNoticesPage(page, live_server.url)
view.goto(ticket_url)
view.open_customer_panel()
view.expect_closed()
view.toggle_notices()
view.expect_open()
expect(page.get_by_role('list', name='اعلامیه‌های اخیر مشتری')).to_contain_text('در انتظار واریز')
expect(page.get_by_role('list', name='اعلامیه‌های اخیر مشتری')).not_to_contain_text(notices[0].subject)
view.toggle_notices()
view.expect_closed()
view.toggle_notices()
view.open_notice(notices[-1].subject)
expect(page).to_have_url(live_server.url + reverse('staff_payments:notice_detail', args=[notices[-1].public_id]))
view.goto(ticket_url)
view.open_customer_panel()
view.toggle_notices()
view.open_all()
expect(page).to_have_url(live_server.url + reverse('staff_payments:desk') + f'?tab=notices&customer={customer.pk}')
expect(page.get_by_text(notices[0].subject, exact=True)).to_be_visible()
+211
View File
@@ -0,0 +1,211 @@
from datetime import timedelta
from uuid import uuid4
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from apps.accounts.magic_access import MAGIC_LINK_SESSION_KEY
from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User
from apps.attachments.models import Attachment, AttachmentPolicy
from apps.customers.models import CustomerProfile
from apps.messaging.models import Message
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket, reply_ticket
pytestmark = pytest.mark.django_db
CONFIRMATION_MESSAGE = (
"شما با حساب دیگری وارد سامانه شده‌اید. برای مشاهده تیکت مرتبط با این لینک، "
"از حساب فعلی خارج شوید. پس از خروج، تیکت به‌صورت خودکار باز می‌شود و نیازی به ورود مجدد ندارید."
)
@pytest.fixture
def magic_case(settings, tmp_path):
settings.MEDIA_ROOT = tmp_path
category = TicketCategory.objects.create(title="لینک امن", slug="magic-confirmation")
owner = User.objects.create_user(username="link-owner", mobile="09121000101", account_type="CUSTOMER")
other = User.objects.create_user(username="link-other", mobile="09121000102", account_type="CUSTOMER")
CustomerProfile.objects.create(user=owner)
CustomerProfile.objects.create(user=other)
ticket = create_ticket(customer=owner, category=category, subject="موضوع محرمانه", body="پیام محرمانه")
sibling = create_ticket(customer=owner, category=category, subject="تیکت خارج از لینک", body="پیام دیگر")
token, link = TicketMagicLink.issue_with_record(ticket)
return owner, other, ticket, sibling, link, reverse("accounts:magic", args=[token])
def confirmation_data(user):
return {"action": "logout_and_open", "account_id": str(user.pk)}
@pytest.mark.parametrize("account_type", ["CUSTOMER", "STAFF"])
def test_mismatched_account_get_requires_confirmation_without_leaking_ticket(client, magic_case, account_type):
owner, other, ticket, sibling, link, url = magic_case
other.account_type = account_type
other.save(update_fields=["account_type"])
client.force_login(other)
response = client.get(url)
content = response.content.decode()
assert response.status_code == 200
assert CONFIRMATION_MESSAGE in content
assert "خروج از حساب و مشاهده تیکت" in content
assert "انصراف از مشاهده این تیکت/پیام" in content
assert ticket.subject not in content and "پیام محرمانه" not in content
assert owner.mobile not in content
assert client.session["_auth_user_id"] == str(other.pk)
assert MAGIC_LINK_SESSION_KEY not in client.session
assert MagicLinkAccessLog.objects.count() == 0
link.refresh_from_db()
assert link.view_count == 0 and link.used_at is None
assert "no-store" in response["Cache-Control"]
assert response["Referrer-Policy"] == "same-origin"
@pytest.mark.parametrize("account_type", ["CUSTOMER", "STAFF"])
def test_confirmed_logout_opens_only_linked_ticket_and_uses_owner_for_reply(client, magic_case, account_type):
owner, other, ticket, sibling, link, url = magic_case
other.account_type = account_type
other.save(update_fields=["account_type"])
client.force_login(other)
session = client.session
session["unrelated_account_data"] = "must disappear"
session.save()
old_session_key = session.session_key
response = client.post(url, confirmation_data(other), follow=True)
assert response.status_code == 200
assert response.redirect_chain == [(reverse("tickets:detail", args=[ticket.public_id]), 302)]
assert "پیام محرمانه" in response.content.decode()
assert "فقط به همین تیکت دسترسی دارید" in response.content.decode()
assert "_auth_user_id" not in client.session
assert "unrelated_account_data" not in client.session
assert client.session.session_key != old_session_key
assert client.session[MAGIC_LINK_SESSION_KEY] == link.pk
for destination in (
reverse("tickets:detail", args=[sibling.public_id]),
reverse("tickets:list"), reverse("tickets:new"), reverse("accounts:profile"), reverse("payments:home"),
):
assert client.get(destination).status_code == 302
for name in ("messages", "poll"):
assert client.get(reverse(f"tickets:{name}", args=[ticket.public_id])).status_code == 200
reply = client.post(reverse("tickets:reply", args=[ticket.public_id]), {"body": "پاسخ پس از خروج"})
assert reply.status_code == 302
assert Message.objects.get(ticket=ticket, body="پاسخ پس از خروج").author_id == owner.pk
link.refresh_from_db()
assert link.view_count == 1
assert MagicLinkAccessLog.objects.filter(magic_link=link).count() == 1
def test_cancel_preserves_current_account_and_does_not_consume_link(client, magic_case):
owner, other, ticket, sibling, link, url = magic_case
client.force_login(other)
client.get(url)
response = client.get(reverse("accounts:home"), follow=True)
assert response.status_code == 200
assert client.session["_auth_user_id"] == str(other.pk)
assert MAGIC_LINK_SESSION_KEY not in client.session
assert MagicLinkAccessLog.objects.count() == 0
@pytest.mark.parametrize("invalid_state", ["expired", "revoked", "inactive", "unknown"])
def test_link_revalidated_before_logout(client, magic_case, invalid_state):
owner, other, ticket, sibling, link, url = magic_case
client.force_login(other)
assert client.get(url).status_code == 200
if invalid_state == "expired":
link.expires_at = timezone.now() - timedelta(seconds=1)
link.save(update_fields=["expires_at"])
elif invalid_state == "revoked":
link.revoked_at = timezone.now()
link.save(update_fields=["revoked_at"])
elif invalid_state == "inactive":
owner.is_active = False
owner.save(update_fields=["is_active"])
else:
url = reverse("accounts:magic", args=["unknown-token"])
response = client.post(url, confirmation_data(other))
assert response.status_code == 302 and response.url == reverse("accounts:login")
assert client.session["_auth_user_id"] == str(other.pk)
assert MAGIC_LINK_SESSION_KEY not in client.session
assert MagicLinkAccessLog.objects.count() == 0
@pytest.mark.parametrize("payload", [{}, {"action": "other"}, {"action": "logout_and_open", "account_id": "wrong"}])
def test_unconfirmed_post_does_not_logout(client, magic_case, payload):
owner, other, ticket, sibling, link, url = magic_case
client.force_login(other)
assert client.post(url, payload).status_code == 200
assert client.session["_auth_user_id"] == str(other.pk)
assert MagicLinkAccessLog.objects.count() == 0
def test_confirmation_from_previous_account_cannot_logout_new_account(client, magic_case):
owner, other, ticket, sibling, link, url = magic_case
client.force_login(other)
client.get(url)
replacement = User.objects.create_user(username="replacement", mobile="09121000103", account_type="CUSTOMER")
client.force_login(replacement)
response = client.post(url, confirmation_data(other))
assert response.status_code == 200
assert client.session["_auth_user_id"] == str(replacement.pk)
assert MagicLinkAccessLog.objects.count() == 0
def test_logout_confirmation_requires_csrf_and_valid_post_succeeds(magic_case):
owner, other, ticket, sibling, link, url = magic_case
client = Client(enforce_csrf_checks=True)
client.force_login(other)
client.get(url)
assert client.post(url, confirmation_data(other)).status_code == 403
assert client.session["_auth_user_id"] == str(other.pk)
response = client.post(url, {**confirmation_data(other), "csrfmiddlewaretoken": client.cookies["csrftoken"].value})
assert response.status_code == 302 and response.url == reverse("tickets:detail", args=[ticket.public_id])
assert "_auth_user_id" not in client.session
@pytest.mark.parametrize("authenticated", [False, True])
def test_existing_anonymous_and_matching_account_flow_remains_direct(client, magic_case, authenticated):
owner, other, ticket, sibling, link, url = magic_case
if authenticated:
client.force_login(owner)
response = client.get(url)
assert response.status_code == 302 and response.url == reverse("tickets:detail", args=[ticket.public_id])
assert client.get(response.url).status_code == 200
assert "no-store" in response["Cache-Control"]
if authenticated:
assert client.session["_auth_user_id"] == str(owner.pk)
assert MAGIC_LINK_SESSION_KEY not in client.session
else:
assert "_auth_user_id" not in client.session
assert client.session[MAGIC_LINK_SESSION_KEY] == link.pk
def test_link_attachment_permissions_remain_scoped_after_logout(client, magic_case):
owner, other, ticket, sibling, link, url = magic_case
AttachmentPolicy.objects.create(attachment_type="PDF", allowed_extensions=[".pdf"], max_file_size=1024 * 1024)
file = SimpleUploadedFile("linked.pdf", b"%PDF-1.4 linked", content_type="application/pdf")
reply_ticket(ticket_id=ticket.pk, user=owner, body="پیوست مجاز", files=[file])
attachment = ticket.ticket_messages.get(body="پیوست مجاز").attachments.get()
internal = Message.objects.create(ticket=ticket, author=other, body="یادداشت داخلی", is_internal=True)
internal_attachment = Attachment.objects.get(pk=attachment.pk)
internal_attachment.pk = None
internal_attachment.public_id = uuid4()
internal_attachment.message = internal
internal_attachment.save()
client.force_login(other)
client.post(url, confirmation_data(other))
assert client.get(reverse("attachments:download", args=[attachment.public_id])).status_code == 200
assert client.get(reverse("attachments:download", args=[internal_attachment.public_id])).status_code == 403
attachment.message = sibling.ticket_messages.first()
attachment.save(update_fields=["message"])
assert client.get(reverse("attachments:download", args=[attachment.public_id])).status_code == 403
@pytest.mark.parametrize("method", ["put", "delete"])
def test_unsupported_method_never_logs_out(client, magic_case, method):
owner, other, ticket, sibling, link, url = magic_case
client.force_login(other)
assert getattr(client, method)(url).status_code == 405
assert client.session["_auth_user_id"] == str(other.pk)
+49
View File
@@ -0,0 +1,49 @@
"""Run against an isolated PostgreSQL test DB; SQLite cannot prove row-lock semantics.
USE_SQLITE=false POSTGRES_* ... pytest tests/test_payment_concurrency.py
Never point POSTGRES_DB at a production database.
"""
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
import pytest
from django.db import connection, close_old_connections
from django.core.exceptions import ValidationError
from apps.accounts.models import User
from apps.notifications.models import NotificationJob
from apps.payments import services
from apps.payments.models import PaymentReceipt, ReceiptDecision
from tests.test_payments import case, submit
pytestmark = [pytest.mark.django_db(transaction=True),
pytest.mark.skipif(connection.vendor != 'postgresql', reason='Requires PostgreSQL row locks')]
@pytest.mark.parametrize('actors,actions,final', [
(('first','second'), ('APPROVE','APPROVE'), 'APPROVED'),
(('first','first'), ('APPROVE','APPROVE'), 'PARTIAL'),
(('first','second'), ('APPROVE','REJECT'), 'REJECTED'),
(('first','senior'), ('APPROVE','SENIOR_APPROVE'), 'APPROVED'),
])
def test_concurrent_decisions_have_one_consistent_final_result(case, actors, actions, final):
receipt = submit(case)
barrier = Barrier(2)
def decide(index):
close_old_connections()
try:
actor = User.objects.get(pk=case[actors[index]].pk)
barrier.wait(timeout=10)
try:
services.decide_receipt(actor, receipt.public_id, 1, actions[index], 'بررسی پرداخت')
return 'recorded'
except ValidationError:
return 'already finalized'
finally:
connection.close()
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = list(pool.map(decide, range(2)))
receipt.refresh_from_db()
assert receipt.status == final
assert receipt.revisions.get().cycle.status == final
assert outcomes.count('recorded') >= 1
votes = ReceiptDecision.objects.filter(cycle__revision__receipt=receipt)
assert votes.count() == (1 if actors[0] == actors[1] else outcomes.count('recorded'))
assert NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').count() == (1 if final == 'APPROVED' else 0)
+260
View File
@@ -0,0 +1,260 @@
"""Permanent regressions for SMS payment capabilities; no real SMS is sent."""
import uuid
from datetime import timedelta
from urllib.parse import urlsplit
import pytest
from django.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import User
from apps.core.models import SiteBranding
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.payments import services
from apps.payments.access import SESSION_KEY, issue_payment_link, token_digest
from apps.payments.models import PaymentAccessLink, PaymentNotification, PaymentReceipt
from apps.payments.notifications import send_payment_sms
from apps.integrations.sms.providers import SMSResult
from tests.test_payments import case, data, upload, submit
pytestmark = pytest.mark.django_db
def open_notice(client, case):
link, url = issue_payment_link(notice=case['notice'])
response = client.get(urlsplit(url).path, follow=True)
assert response.status_code == 200
return link
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')
class Provider:
def send_message(self, mobile, text):
return SMSResult(True, 'test-provider')
send_payment_sms(job, provider=Provider())
notice_sms = PaymentNotification.objects.get(job=job)
url = notice_sms.context['payment_link']
token = urlsplit(url).path.rstrip('/').rsplit('/', 1)[-1]
assert notice_sms.access_link.token_hash == token_digest(token)
assert notice_sms.access_link.expires_at > timezone.now() + timedelta(days=2)
first_body = notice_sms.rendered_body
send_payment_sms(job, provider=Provider())
notice_sms.refresh_from_db()
assert notice_sms.rendered_body == first_body and PaymentAccessLink.objects.count() == 1
for _ in range(2):
response = client.get(urlsplit(url).path, follow=True)
assert response.status_code == 200
assert case['notice'].deposit_id in response.content.decode()
assert '_auth_user_id' not in client.session
notice_sms.access_link.refresh_from_db()
assert notice_sms.access_link.view_count == 2
def test_public_notice_preserves_copy_warning_and_database_signature(case, client):
branding = SiteBranding.load()
branding.developer_credit_text = 'امضای توسعه‌دهنده از دیتابیس'
branding.save()
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 'کپی کامل اطلاعات' in html and 'pay-deposit-required' in html
assert case['account'].name in html
assert 'نام حساب: ' + case['account'].name in response.context['notice'].copy_text
assert 'واریز بدون این شناسه معتبر نخواهد بود' in html
assert html.count(branding.developer_credit_text) == 1
assert 'no-store' in response['Cache-Control'] and response['X-Robots-Tag'] == 'noindex, nofollow'
assert reverse('accounts:profile') not in html
@pytest.mark.parametrize('linked', [True, False])
@pytest.mark.django_db(transaction=True)
def test_anonymous_link_can_submit_linked_and_independent_receipts(case, client, linked):
link = open_notice(client, case)
values = data(case, linked=linked, deposit_id='')
response = client.post(reverse('payments:receipt_new'), {**values, 'receipt_image': upload()})
assert response.status_code == 302
receipt = PaymentReceipt.objects.get()
assert receipt.customer == case['customer'] and receipt.submitted_by == case['customer']
assert receipt.notice_id == (case['notice'].pk if linked else None)
assert link.submitted_receipts.filter(pk=receipt.pk).exists()
assert client.get(response.url).status_code == 200
image = client.get(reverse('payments:receipt_image', args=[receipt.public_id]))
assert image.status_code == 200 and 'no-store' in image['Cache-Control']
assert b''.join(image.streaming_content)
# A browser retry of the same upload is idempotent.
assert client.post(reverse('payments:receipt_new'), {**values, 'receipt_image': upload()}).status_code == 302
assert PaymentReceipt.objects.count() == 1
assert '_auth_user_id' not in client.session
def test_scope_blocks_other_customers_and_other_notices_of_same_customer(case, client):
unrelated = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': 'اعلامیه خارج از لینک', 'amount': 100, 'deposit_id': '1'})
another = User.objects.create_user(username='link-other-customer', account_type='CUSTOMER')
foreign = services.issue_notice(case['creator'], {'customer': another, 'account': case['account'],
'subject': 'خصوصی مشتری دیگر', 'amount': 100, 'deposit_id': '2'})
open_notice(client, case)
for notice in [unrelated, foreign]:
assert client.get(reverse('payments:notice_detail', args=[notice.public_id])).status_code == 404
assert client.get(reverse('payments:receipt_new'), {'notice': notice.public_id}).status_code == 404
response = client.post(reverse('payments:receipt_new'), {**data(case, notice_id=notice.pk), 'receipt_image': upload()})
assert response.status_code == 200 and 'notice_id' in response.context['form'].errors
assert not PaymentReceipt.objects.exists()
assert client.get(reverse('payments:home')).status_code == 302
assert client.get(reverse('payments:notices')).status_code == 302
form = client.get(reverse('payments:receipt_new')).context['form']
assert list(form.fields['notice_id'].queryset.values_list('pk', flat=True)) == [case['notice'].pk]
@pytest.mark.parametrize('invalidator', ['expired', 'revoked', 'inactive'])
def test_expiry_revocation_and_inactive_customer_apply_to_existing_session(case, client, invalidator):
link = open_notice(client, case)
if invalidator == 'expired':
PaymentAccessLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1))
elif invalidator == 'revoked':
PaymentAccessLink.objects.filter(pk=link.pk).update(revoked_at=timezone.now())
else:
User.objects.filter(pk=case['customer'].pk).update(is_active=False)
response = client.post(reverse('payments:receipt_new'), {**data(case), 'receipt_image': upload()})
assert response.status_code == 410 and not PaymentReceipt.objects.exists()
assert SESSION_KEY not in client.session
def test_invalid_link_and_uuid_are_not_public_capabilities(case, client):
assert client.get(reverse('payments:open_link', args=['not-a-token'])).status_code == 410
assert client.get(reverse('payments:open_link', args=['A' * 43])).status_code == 410
assert client.get(reverse('payments:notice_detail', args=[case['notice'].public_id])).status_code == 302
def test_wrong_account_requires_explicit_confirmation(case, client):
_, url = issue_payment_link(notice=case['notice'])
client.force_login(case['outsider'])
path = urlsplit(url).path
assert client.get(path).status_code == 200
assert '_auth_user_id' in client.session and SESSION_KEY not in client.session
assert client.post(path, {'action': 'logout_and_open', 'account_id': 'wrong'}).status_code == 200
assert client.post(path, {'action': 'logout_and_open', 'account_id': case['outsider'].pk}).status_code == 302
assert '_auth_user_id' not in client.session and SESSION_KEY in client.session
def test_link_forms_keep_csrf_and_field_errors(case):
client = Client(enforce_csrf_checks=True)
open_notice(client, case)
url = reverse('payments:receipt_new')
assert client.post(url, {'paid_amount': '100'}).status_code == 403
client.get(url)
token = client.cookies['csrftoken'].value
response = client.post(url, {'csrfmiddlewaretoken': token, 'paid_amount': '۱۲۳٬۴۵۶', 'receipt_mode': 'independent', 'submission_key': uuid.uuid4()})
assert response.status_code == 200 and response.context['form'].errors
assert response.context['form']['paid_amount'].value() == '۱۲۳٬۴۵۶'
def test_sms_allowed_tags_follow_the_event_even_without_model_clean(case):
template = SMSTemplate.objects.get(event_type='PAYMENT_NOTICE_CREATED', unit__isnull=True)
template.allowed_tags = ['magic_link']
template.save(update_fields=['body'])
template.refresh_from_db()
assert 'payment_link' in template.allowed_tags and 'magic_link' not in template.allowed_tags
assert '{payment_link}' in template.supported_tags
assert 'magic_link' in SMSTemplate.tags_for_event('STAFF_REPLY')
template.body = '{magic_link}'
with pytest.raises(ValidationError):
template.clean()
template.body = '{payment_link'
with pytest.raises(ValidationError):
template.clean()
def test_approval_sms_link_opens_the_correct_receipt_without_login(case, client):
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE')
services.decide_receipt(case['second'], receipt.public_id, 1, 'APPROVE')
job = NotificationJob.objects.get(event_type='PAYMENT_RECEIPT_APPROVED')
class Provider:
def send_message(self, mobile, text):
return SMSResult(True, 'approval-test')
send_payment_sms(job, provider=Provider())
notification = PaymentNotification.objects.get(job=job)
assert notification.access_link.receipt_id == receipt.pk
response = client.get(urlsplit(notification.context['payment_link']).path, follow=True)
assert response.status_code == 200 and receipt.number in response.content.decode()
assert '_auth_user_id' not in client.session
assert 'ثبت تأیید من' not in response.content.decode()
def test_receipt_scope_images_revocation_and_rejected_revision(case, client):
receipt = submit(case)
another_notice = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': 'خارج از دسترسی', 'amount': 100, 'deposit_id': '3'})
form_data = {**data(case, notice_id=another_notice.pk), 'receipt_image': upload()}
from apps.payments.forms import ReceiptForm
form = ReceiptForm(form_data, {'receipt_image': form_data.pop('receipt_image')}, customer=case['customer'])
assert form.is_valid()
unrelated = services.submit_receipt(case['customer'], case['customer'], form.cleaned_data)
link = open_notice(client, case)
assert client.get(reverse('payments:receipt_detail', args=[unrelated.public_id])).status_code == 404
assert client.get(reverse('payments:receipt_image', args=[unrelated.public_id])).status_code == 403
assert client.post(reverse('staff_payments:decision', args=[receipt.public_id]), {'version': 1, 'action': 'APPROVE'}).status_code == 302
receipt.refresh_from_db()
assert receipt.status == 'PENDING'
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خواناتر ارسال شود')
revise = reverse('payments:receipt_revise', args=[receipt.public_id])
assert client.get(revise).status_code == 200
values = {**data(case), 'receipt_image': upload(), 'version': 1}
assert client.post(revise, values).status_code == 302
receipt.refresh_from_db()
assert receipt.current_version == 2 and receipt.status == 'PENDING'
PaymentAccessLink.objects.filter(pk=link.pk).update(revoked_at=timezone.now())
assert client.get(reverse('payments:receipt_image', args=[receipt.public_id])).status_code == 410
def test_cached_unsent_sms_is_upgraded_without_rewriting_custom_text(case):
job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
notification = PaymentNotification.objects.get(job=job)
old_url = notification.context['payment_link']
notification.rendered_body = 'متن محفوظ قبل و بعد ' + old_url
notification.save(update_fields=['rendered_body'])
class Provider:
def send_message(self, mobile, text):
return SMSResult(True, 'upgrade-test')
send_payment_sms(job, provider=Provider())
notification.refresh_from_db()
assert notification.access_link_id
assert notification.rendered_body == 'متن محفوظ قبل و بعد ' + notification.context['payment_link']
def test_staff_can_issue_a_fresh_copyable_link_without_sending_sms(case, client):
client.force_login(case['creator'])
url = reverse('staff_payments:notice_payment_link', args=[case['notice'].public_id])
assert client.get(url).status_code == 405
response = client.post(url, follow=True)
assert response.status_code == 200
share_url = response.context['payment_share_url']
assert f'data-copy="{share_url}"' in response.content.decode()
assert PaymentAccessLink.objects.count() == 1
assert NotificationJob.objects.filter(event_type='PAYMENT_NOTICE_CREATED').count() == 1
client.logout()
assert client.get(urlsplit(share_url).path, follow=True).status_code == 200
def test_submission_service_enforces_link_scope_and_validity(case):
from apps.payments.forms import ReceiptForm
link, _ = issue_payment_link(notice=case['notice'])
another = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': 'خارج از لینک', 'amount': 100, 'deposit_id': '4'})
form = ReceiptForm(data(case, notice_id=another.pk), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid()
with pytest.raises(PermissionDenied):
services.submit_receipt(case['customer'], case['customer'], form.cleaned_data, access_link=link)
PaymentAccessLink.objects.filter(pk=link.pk).update(revoked_at=timezone.now())
independent = ReceiptForm(data(case, linked=False), {'receipt_image': upload()}, customer=case['customer'])
assert independent.is_valid()
with pytest.raises(PermissionDenied):
services.submit_receipt(case['customer'], case['customer'], independent.cleaned_data, access_link=link)
assert not PaymentReceipt.objects.exists()
+718
View File
@@ -0,0 +1,718 @@
import base64
import uuid
import jdatetime
import pytest
from django.contrib.auth.models import Permission
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import User
from apps.staff.models import StaffProfile, OrganizationalUnit
from apps.attachments.models import AttachmentPolicy
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.integrations.sms.providers import SMSResult
from apps.payments import services, policies
from apps.payments.forms import ReceiptForm, NoticeForm
from apps.payments.models import (BankInstitution, CompanyBankAccount, PaymentApprovalPolicy, PaymentReceipt,
PaymentAccessConfiguration, PaymentAuditEvent, ReceiptDecision, PaymentNotificationAttempt)
pytestmark = pytest.mark.django_db
PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=')
def upload():
return SimpleUploadedFile('receipt.png', PNG, content_type='image/png')
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']:
user.__dict__.pop(attr,None)
@pytest.fixture
def case(settings, tmp_path):
settings.MEDIA_ROOT = tmp_path
unit = OrganizationalUnit.objects.create(name='فروش مالی',slug='payment-sales')
users = {}
for key in ['creator','first','second','senior','outsider']:
user = User.objects.create_user(username='pay-'+key,account_type='STAFF',password='TestPayment123!')
StaffProfile.objects.create(user=user,display_name=key,primary_unit=unit)
users[key] = user
customer = User.objects.create_user(username='pay-customer',mobile='09120009999',account_type='CUSTOMER')
# Transactional PostgreSQL tests flush seed data between cases.
bank, _ = BankInstitution.objects.get_or_create(code='melli',defaults={'name':'ملی ایران'})
account = CompanyBankAccount.objects.create(name='حساب فروش ملی',holder='شرکت نمونه',bank=bank,account_number='000123456',requires_deposit_id=True)
AttachmentPolicy.objects.update_or_create(attachment_type='IMAGE',defaults={'max_file_size':10*1024*1024,'allowed_extensions':['.png'],'allowed_mime_types':['image/png']})
policy = PaymentApprovalPolicy.objects.create(name='دو تأیید',unit=unit)
policy.reviewers.add(users['first'],users['second']);policy.seniors.add(users['senior'])
for key in ['first','second']:
grant(users[key],'approve_paymentreceipt')
grant(users['senior'],'senior_approve_paymentreceipt')
notice = services.issue_notice(users['creator'],{'customer':customer,'account':account,'subject':'پرداخت فاکتور','amount':'۲۸۰٬۰۰۰٬۰۰۰','deposit_id':'۰۰۰۱۲۳','invoice_number':'INV-1','description':'توضیح همیشه نمایان'})
return {**users,'customer':customer,'account':account,'notice':notice,'policy':policy,'unit':unit}
def data(case, linked=True, **extra):
today = jdatetime.date.fromgregorian(date=timezone.localdate())
return {'receipt_mode':'linked' if linked else 'independent','notice_id':case['notice'].pk if linked else '',
'paid_amount':'۱۲۳۴۵','destination_account':case['account'].pk,'deposit_id':'000123',
'paid_day':str(today.day),'paid_month':str(today.month),'paid_year':str(today.year),
'submission_key':str(uuid.uuid4()), **extra}
def submit(case, linked=True):
form = ReceiptForm(data(case,linked),{'receipt_image':upload()},customer=case['customer'])
assert form.is_valid(), form.errors
return services.submit_receipt(case['customer'],case['customer'],form.cleaned_data)
def test_identifier_required_in_form_and_service(case):
values = {'customer':case['customer'].pk,'account':case['account'].pk,'subject':'فاکتور','amount':'100'}
form = NoticeForm(values)
assert not form.is_valid() and 'deposit_id' in form.errors
with pytest.raises(ValidationError):
services.issue_notice(case['creator'],{'customer':case['customer'],'account':case['account'],'subject':'فاکتور','amount':100})
assert NotificationJob.objects.filter(event_type='PAYMENT_NOTICE_CREATED').count() == 1
def test_multireviewer_and_final_sms_are_exactly_one(case):
receipt = submit(case)
services.decide_receipt(case['first'],receipt.public_id,1,'APPROVE')
receipt.refresh_from_db();assert receipt.status == 'PARTIAL'
assert not NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').exists()
services.decide_receipt(case['first'],receipt.public_id,1,'APPROVE')
assert ReceiptDecision.objects.count() == 1
services.decide_receipt(case['second'],receipt.public_id,1,'APPROVE')
receipt.refresh_from_db();assert receipt.status == 'APPROVED'
services.decide_receipt(case['second'],receipt.public_id,1,'APPROVE')
assert NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').count() == 1
assert receipt.revisions.get().cycle.finalized_by == case['second']
def test_senior_explicit_override_and_reason(case):
receipt = submit(case)
with pytest.raises(ValidationError):
services.decide_receipt(case['senior'],receipt.public_id,1,'SENIOR_APPROVE')
services.decide_receipt(case['senior'],receipt.public_id,1,'SENIOR_APPROVE','بررسی تطبیق انجام شد')
receipt.refresh_from_db();assert receipt.status == 'APPROVED'
assert ReceiptDecision.objects.get().actor_name == 'senior'
assert PaymentAuditEvent.objects.filter(action='SENIOR_APPROVE').exists()
@pytest.mark.parametrize('who',['customer','creator','outsider'])
def test_view_and_create_never_imply_approval(case,who):
receipt = submit(case)
with pytest.raises(PermissionDenied):
services.decide_receipt(case[who],receipt.public_id,1,'APPROVE')
def test_rejection_ends_cycle_and_preserves_evidence(case):
receipt = submit(case)
services.decide_receipt(case['first'],receipt.public_id,1,'REJECT','تصویر پرداخت خوانا نیست')
with pytest.raises(ValidationError):
services.decide_receipt(case['second'],receipt.public_id,1,'APPROVE')
with pytest.raises(ValidationError):
services.decide_receipt(case['senior'],receipt.public_id,1,'SENIOR_APPROVE','override')
assert not NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').exists()
def test_policy_snapshot_and_stale_version(case):
receipt = submit(case)
case['policy'].reviewers.clear()
with pytest.raises(ValidationError):
services.decide_receipt(case['first'],receipt.public_id,2,'APPROVE')
services.decide_receipt(case['first'],receipt.public_id,1,'APPROVE')
assert receipt.revisions.get().cycle.assignments.filter(is_senior=False).count() == 2
def test_snapshot_survives_bank_change(case,client):
case['account'].account_number = '9999999';case['account'].save()
case['notice'].refresh_from_db();assert case['notice'].account_snapshot['account_number'] == '000123456'
client.force_login(case['customer'])
page = client.get(reverse('payments:notices')).content.decode()
assert '000123456' in page and '9999999' not in page and 'توضیح همیشه نمایان' in page
def test_independent_receipt_and_server_date(case,client):
# Independent policies use global fallback, not an arbitrary unit's ticket rights.
case['policy'].unit=None;case['policy'].save()
receipt = submit(case,False)
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'})
assert receipt.number in response.content.decode()
assert client.get(reverse('staff_payments:count')).json()['pending_receipts'] == 1
@pytest.mark.parametrize('changes,field',[
({'paid_amount':'0'},'paid_amount'),({'paid_amount':'12.5'},'paid_amount'),({'paid_amount':'1,23'},'paid_amount'),
({'paid_day':''},'paid_day'),({'paid_date':'1990-01-01'},'paid_day'),({'destination_account':''},'destination_account')])
def test_validation_exact_fields(case,changes,field):
form = ReceiptForm(data(case,**changes),{'receipt_image':upload()},customer=case['customer'])
assert not form.is_valid() and field in form.errors
def test_cross_customer_notice_and_image_protection(case,client):
receipt=submit(case)
other=User.objects.create_user(username='other-customer',account_type='CUSTOMER')
client.force_login(other)
assert client.get(reverse('payments:receipt_detail',args=[receipt.public_id])).status_code == 404
assert client.get(reverse('payments:receipt_image',args=[receipt.public_id])).status_code == 403
form=ReceiptForm(data(case),{'receipt_image':upload()},customer=other)
assert not form.is_valid() and 'notice_id' in form.errors
client.force_login(case['customer'])
response=client.get(reverse('payments:receipt_image',args=[receipt.public_id]))
assert response.status_code == 200 and 'no-store' in response['Cache-Control']
assert b''.join(response.streaming_content) == PNG
def test_nonce_duplicate_is_idempotent_and_different_values_rejected(case):
values=data(case)
for i in range(2):
form=ReceiptForm(values,{'receipt_image':upload()},customer=case['customer']);assert form.is_valid()
services.submit_receipt(case['customer'],case['customer'],form.cleaned_data)
assert PaymentReceipt.objects.count() == 1
values['paid_amount']='999'
form=ReceiptForm(values,{'receipt_image':upload()},customer=case['customer']);assert form.is_valid()
with pytest.raises(ValidationError):
services.submit_receipt(case['customer'],case['customer'],form.cleaned_data)
def test_destination_flags_and_bank_permission(case,client):
case['account'].available_to_customers=False;case['account'].save()
linked=ReceiptForm(data(case),{'receipt_image':upload()},customer=case['customer']);assert linked.is_valid()
independent=ReceiptForm(data(case,False),{'receipt_image':upload()},customer=case['customer']);assert not independent.is_valid()
client.force_login(case['outsider']);assert client.get(reverse('staff_payments:banks')).status_code == 403
grant(case['creator'],'manage_company_bankaccounts');client.force_login(case['creator'])
assert client.get(reverse('staff_payments:banks')).status_code == 200
client.post(reverse('staff_payments:bank_bulk'),{'accounts':[case['account'].pk],'field':'is_active','value':'0'})
case['account'].refresh_from_db();assert not case['account'].is_active
assert PaymentAuditEvent.objects.filter(action='BANK_FLAG_CHANGED').exists()
def test_missing_policy_stays_unapproved_until_manager_assigns(case):
case['policy'].is_active=False;case['policy'].save()
receipt=submit(case)
assert not receipt.revisions.get().cycle.assignments.exists()
with pytest.raises(PermissionDenied):
services.decide_receipt(case['first'],receipt.public_id,1,'APPROVE')
case['policy'].is_active=True;case['policy'].save();grant(case['creator'],'manage_payment_approval_policy')
services.bind_unassigned_cycle(case['creator'],receipt)
assert receipt.revisions.get().cycle.assignments.count() == 3
def test_sms_uses_database_template_and_attempt_history(case,monkeypatch):
from apps.notifications.management.commands import process_notifications
template=SMSTemplate.objects.get(event_type='PAYMENT_NOTICE_CREATED',unit__isnull=True)
template.body='متن سفارشی {notice_number} {payment_link}';template.save()
captured=[]
class Provider:
def send_message(self,mobile,message):
captured.append(message);return SMSResult(True,'123')
monkeypatch.setattr(process_notifications,'get_sms_provider',lambda:Provider())
job=NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
process_notifications._send_sms(job)
template.body='جدید';template.save()
process_notifications._send_sms(job)
assert captured[0] == captured[1] and 'متن سفارشی' in captured[0] and '/chaharsu/' in captured[0]
assert PaymentNotificationAttempt.objects.filter(success=True).count() == 2
assert job.ticket is None
def test_explicit_access_and_inactive_reviewer(case):
receipt=submit(case)
PaymentAccessConfiguration.objects.update(open_view=False,open_create=False)
assert not policies.visible(PaymentReceipt.objects.all(),case['outsider']).exists()
assert not policies.can_create(case['outsider'])
StaffProfile.objects.filter(user=case['first']).update(is_active=False)
actor=User.objects.get(pk=case['first'].pk)
with pytest.raises(PermissionDenied):
services.decide_receipt(actor,receipt.public_id,1,'APPROVE')
def test_login_hub_and_existing_new_route(case,client):
client.force_login(case['customer'])
assert client.get('/').url == reverse('payments:home')
assert client.get(reverse('payments:home')).status_code == 200
assert client.get(reverse('tickets:new')).status_code == 200
assert reverse('tickets:new') == '/tickets/new/'
client.logout()
assert client.get(reverse('payments:home')).status_code == 302
def test_upload_spoof_and_missing_fields(case):
form=ReceiptForm(data(case),{'receipt_image':SimpleUploadedFile('fake.png',b'%PDF-1.4',content_type='image/png')},customer=case['customer'])
assert not form.is_valid() and 'receipt_image' in form.errors
form=ReceiptForm({},customer=case['customer'])
assert not form.is_valid()
assert {'paid_amount','paid_day','paid_month','paid_year','receipt_image','destination_account'} <= set(form.errors)
def test_revision_preserves_old_votes_and_restarts_full_policy(case):
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()
services.revise_receipt(case['customer'],receipt.public_id,1,form.cleaned_data)
receipt.refresh_from_db();assert receipt.status=='PENDING' and receipt.current_version==2
assert receipt.revisions.count()==2
assert receipt.revisions.get(version=1).cycle.decisions.count()==1
assert receipt.revisions.get(version=2).cycle.decisions.count()==0
with pytest.raises(ValidationError):
services.decide_receipt(case['second'],receipt.public_id,1,'APPROVE')
services.decide_receipt(case['first'],receipt.public_id,2,'APPROVE')
receipt.refresh_from_db();assert receipt.status=='PARTIAL'
def test_rollback_cleans_uploaded_file(case,settings):
case['policy'].reviewers.clear()
with pytest.raises(ValidationError):
submit(case)
assert PaymentReceipt.objects.count()==0
assert not list(settings.MEDIA_ROOT.rglob('*.png'))
def test_invalid_notice_query_is_not_server_error(case,client):
client.force_login(case['customer'])
assert client.get(reverse('payments:receipt_new'),{'notice':'not-a-uuid'}).status_code==404
def test_safe_login_next_and_magic_link_not_financial_session(case,client):
from apps.accounts.models import TicketMagicLink
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket
case['customer'].set_password('TestFinancial123!');case['customer'].save()
target=reverse('payments:notices')
client.get(reverse('accounts:password_login'),{'next':target})
response=client.post(reverse('accounts:password_login'),{'mobile':case['customer'].local_mobile,'password':'TestFinancial123!'})
assert response.url==target
client.logout();client.get(reverse('accounts:password_login'),{'next':'https://evil.example/chaharsu/'})
response=client.post(reverse('accounts:password_login'),{'mobile':case['customer'].local_mobile,'password':'TestFinancial123!'})
assert response.url==reverse('payments:home')
client.logout()
category=TicketCategory.objects.create(title='magic finance guard',slug='magic-finance-guard')
ticket=create_ticket(customer=case['customer'],category=category,subject='صرفاً تیکت',body='متن')
client.get(reverse('accounts:magic',args=[TicketMagicLink.issue(ticket)]))
assert client.get(reverse('payments:notices')).status_code==302
def test_notice_nonce_prevents_duplicate_issuance_and_sms(case):
values = {'customer':case['customer'], 'account':case['account'], 'subject':'دستور تازه',
'amount':1000, 'deposit_id':'0042', 'submission_key':uuid.uuid4()}
first = services.issue_notice(case['creator'], values)
second = services.issue_notice(case['creator'], values)
assert first.pk == second.pk
assert NotificationJob.objects.filter(event_type='PAYMENT_NOTICE_CREATED').count() == 2
with pytest.raises(ValidationError):
services.issue_notice(case['creator'], {**values, 'amount':2000})
def test_revision_retains_notice_snapshot_and_identifier(case):
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'مدرک تازه لازم است')
case['account'].account_number = '999999';case['account'].save()
form = ReceiptForm(data(case, deposit_id=''), {'receipt_image':upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
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'
def test_financial_unit_sms_template_accepts_financial_tags(case):
from apps.tickets.forms import UnitSMSTemplateForm
form = UnitSMSTemplateForm({'unit':case['unit'].pk, 'event_type':'PAYMENT_NOTICE_CREATED',
'title':'مالی', 'body':'{notice_number} {amount_rial} {payment_link}', 'is_active':True},
user=case['creator'], unit_ids=[case['unit'].pk])
assert form.is_valid(), form.errors
assert 'payment_link' in form.save().allowed_tags
def test_payment_posts_enforce_csrf_and_revoked_approval(case):
from django.test import Client
receipt = submit(case)
client = Client(enforce_csrf_checks=True)
client.force_login(case['first'])
endpoint = reverse('staff_payments:decision',args=[receipt.public_id])
assert client.post(endpoint, {'version':1,'action':'APPROVE'}).status_code == 403
case['first'].user_permissions.clear()
actor = User.objects.get(pk=case['first'].pk)
with pytest.raises(PermissionDenied):
services.decide_receipt(actor,receipt.public_id,1,'APPROVE')
receipt.refresh_from_db();assert receipt.status == 'PENDING'
assert not ReceiptDecision.objects.exists()
def test_receipt_identifier_is_optional_even_for_a_different_identifier_account(case):
other = CompanyBankAccount.objects.create(name='حساب شناسه‌دار دیگر',holder='شرکت',
bank=case['account'].bank,account_number='1111',requires_deposit_id=True)
form = ReceiptForm(data(case,destination_account=other.pk,deposit_id=''),
{'receipt_image':upload()},customer=case['customer'])
assert form.is_valid(), form.errors
receipt = services.submit_receipt(case['customer'], case['customer'], form.cleaned_data)
assert receipt.revisions.get(version=1).deposit_id == ''
@pytest.mark.parametrize('day,month,year,valid', [
('30','12','1403',True),('30','12','1402',False),('31','7','1403',False),
('31','6','1403',True),('','1','1403',False),
])
def test_jalali_calendar_validation_and_gregorian_result(day,month,year,valid):
from apps.payments import validators
import datetime
if valid:
assert isinstance(validators.paid_date(day,month,year), datetime.date)
else:
with pytest.raises(ValidationError):
validators.paid_date(day,month,year)
def test_failed_sms_template_preparation_has_attempt_history(case):
from apps.payments.notifications import send_payment_sms
SMSTemplate.objects.filter(event_type='PAYMENT_NOTICE_CREATED').update(is_active=False)
job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
with pytest.raises(SMSTemplate.DoesNotExist):
send_payment_sms(job,provider_factory=lambda:None)
attempt = PaymentNotificationAttempt.objects.get()
assert attempt.success is False and attempt.finished_at is not None
assert 'قالب پیامک' in attempt.error
def test_stale_bank_form_does_not_overwrite_new_flags(case,client):
grant(case['creator'],'manage_company_bankaccounts')
client.force_login(case['creator'])
account = case['account']
token = account.updated_at.isoformat()
account.is_active = False;account.save()
response = client.post(reverse('staff_payments:bank_edit',args=[account.public_id]),{
'revision_token':token,'name':'نام قدیمی','bank':account.bank_id,'holder':account.holder,
'account_number':account.account_number,'sort_order':0,'is_active':'on'})
assert response.status_code == 200
assert 'تغییر کرده است' in response.content.decode()
account.refresh_from_db();assert not account.is_active and account.name != 'نام قدیمی'
def test_customer_receipt_list_and_revised_action_queue(case,client):
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()
services.revise_receipt(case['customer'],receipt.public_id,1,form.cleaned_data)
client.force_login(case['first'])
assert receipt.number in client.get(reverse('staff_payments:desk'),{'mine':'1'}).content.decode()
client.force_login(case['customer'])
assert receipt.number in client.get(reverse('payments:receipt_list')).content.decode()
def test_pwa_entry_routes_each_authenticated_role(case,client):
assert client.get(reverse('web_manifest')).json()['start_url'] == '/'
client.force_login(case['customer'])
assert client.get('/').url == reverse('payments:home')
client.force_login(case['creator'])
assert client.get('/').url == reverse('staff_tickets:list')
def test_financial_sms_model_clean_supplies_admin_readonly_tags(case):
template=SMSTemplate(unit=case['unit'],event_type='PAYMENT_RECEIPT_APPROVED',title='مالی',
body='{receipt_number} {payment_link}')
template.full_clean()
assert 'payment_link' in template.allowed_tags
template.body='{magic_link}'
with pytest.raises(ValidationError):
template.full_clean()
def test_cached_user_cannot_vote_after_permission_revocation(case):
receipt=submit(case)
actor=case['first']
assert actor.has_perm('payments.approve_paymentreceipt')
actor.user_permissions.clear()
with pytest.raises(PermissionDenied):
services.decide_receipt(actor,receipt.public_id,1,'APPROVE')
assert not ReceiptDecision.objects.exists()
def test_independent_receipt_bank_numbers_are_only_in_staff_review(case,client):
receipt=submit(case,False)
client.force_login(case['customer'])
content=client.get(reverse('payments:receipt_detail',args=[receipt.public_id])).content.decode()
assert 'شماره حساب مقصد ثبت‌شده' not in content
client.force_login(case['first'])
content=client.get(reverse('staff_payments:receipt_detail',args=[receipt.public_id])).content.decode()
assert 'شماره حساب مقصد ثبت‌شده' in content and '000123456' in content
def test_audit_permission_opens_readonly_evidence_admin(case):
from django.contrib import admin
from django.test import RequestFactory
from apps.payments.admin import EvidenceAdmin
request=RequestFactory().get('/x5admin/payments/paymentauditevent/')
request.user=case['outsider']
controller=EvidenceAdmin(PaymentAuditEvent,admin.site)
assert not controller.has_view_permission(request)
grant(request.user,'view_payment_audit')
assert controller.has_view_permission(request)
assert controller.get_queryset(request).exists()
assert not controller.has_change_permission(request)
assert not controller.has_delete_permission(request)
def test_admin_bank_audit_contains_previous_and_new_flags(case):
from django.contrib import admin
from django.test import RequestFactory
from apps.payments.admin import AccountAdmin
from apps.payments.forms import BankAccountForm
account=case['account']
grant(case['creator'],'manage_company_bankaccounts')
values={'revision_token':account.updated_at.isoformat(),'name':'حساب ویرایش‌شده','bank':account.bank_id,
'holder':account.holder,'account_number':account.account_number,'sort_order':0,
'requires_deposit_id':'on','available_to_staff':'on','available_to_customers':'on'}
form=BankAccountForm(values,instance=account)
assert form.is_valid(),form.errors
request=RequestFactory().post('/x5admin/payments/companybankaccount/')
request.user=case['creator']
AccountAdmin(CompanyBankAccount,admin.site).save_model(request,form.save(commit=False),form,True)
event=PaymentAuditEvent.objects.get(action='CONFIG_SAVED')
assert event.details['before']['is_active'] is True
assert event.details['after']['is_active'] is False
assert event.actor_id==request.user.pk
def test_customer_search_identity_history_and_permissions(case, client):
from apps.customers.models import CustomerProfile
from apps.tickets.models import Ticket, TicketCategory
customer = case['customer']
CustomerProfile.objects.create(user=customer, first_name='محمد', last_name='رضایی',
organization_name='شرکت آفتاب', city='قم')
category = TicketCategory.objects.create(title='جست‌وجوی مالی', slug='payment-picker')
visible = Ticket.objects.create(customer=customer, category=category, subject='سابقه خرید قابل مشاهده',
owner=case['creator'], selected_expert=case['creator'], origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
Ticket.objects.create(customer=customer, category=category, subject='سابقه محرمانه دیگر',
owner=case['outsider'], selected_expert=case['outsider'], origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
inactive = User.objects.create_user(username='inactive-picker', mobile='09123334444', is_active=False)
CustomerProfile.objects.create(user=inactive, first_name='محمد', last_name='رضایی')
client.force_login(case['creator'])
url = reverse('staff_payments:customer_search')
for query in ['محمد رضایی', 'شرکت آفتاب', '۰۹۱۲۰۰۰۹۹۹۹', '0912', visible.ticket_number, 'سابقه خرید قابل مشاهده']:
response = client.get(url, {'q': query})
assert response.status_code == 200
assert 'no-store' in response['Cache-Control']
result = response.json()['results'][0]
assert result['id'] == str(customer.public_id)
assert result['name'] == 'محمد رضایی'
assert result['mobile'] == customer.local_mobile
assert result['ticket_count'] == 1
assert [row['subject'] for row in result['history']] == [visible.subject]
assert client.get(url, {'q': 'سابقه محرمانه دیگر'}).json()['results'] == []
assert client.get(url, {'q': 'م'}).json()['results'] == []
configuration = PaymentAccessConfiguration.objects.first()
configuration.open_create = False
configuration.save()
assert client.get(url, {'q': 'محمد'}).status_code == 403
client.force_login(customer)
assert client.get(url, {'q': 'محمد'}).status_code == 403
def test_notice_customer_prefill_uses_uuid_and_no_full_directory(case, client):
client.force_login(case['creator'])
url = reverse('staff_payments:notice_new')
response = client.get(url, {'customer': str(case['customer'].public_id)})
assert response.status_code == 200
assert response.context['form']['customer'].value() == case['customer'].pk
assert response.context['customer_selection']['id'] == str(case['customer'].public_id)
html = response.content.decode()
assert 'type="hidden" name="customer"' in html
assert '<select name="customer"' not in html
assert client.get(url, {'customer': 'invalid'}).status_code == 404
assert client.get(url, {'customer': str(uuid.uuid4())}).status_code == 404
case['customer'].is_active = False
case['customer'].save()
assert client.get(url, {'customer': str(case['customer'].public_id)}).status_code == 404
def test_payment_shortcuts_on_contact_and_ticket(case, client):
from apps.tickets.models import Ticket, TicketCategory
ticket = Ticket.objects.create(customer=case['customer'], category=TicketCategory.objects.create(title='میان‌بر مالی', slug='payment-shortcut'),
subject='تیکت برای ایجاد اعلامیه', owner=case['creator'], selected_expert=case['creator'],
origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
client.force_login(case['creator'])
target = reverse('staff_payments:notice_new') + '?customer=' + str(case['customer'].public_id)
for url in [reverse('staff_tickets:detail', args=[ticket.public_id]), reverse('staff_tickets:contacts')]:
response = client.get(url)
assert response.status_code == 200
assert target in response.content.decode()
configuration = PaymentAccessConfiguration.objects.first()
configuration.open_create = False
configuration.save()
assert target not in client.get(reverse('staff_tickets:detail', args=[ticket.public_id])).content.decode()
def test_ticket_recent_notices_are_customer_scoped_and_permission_filtered(case, client):
from apps.payments.models import PaymentNotice
from apps.tickets.models import Ticket, TicketCategory
ticket = Ticket.objects.create(customer=case['customer'],
category=TicketCategory.objects.create(title='اعلامیه‌ها', slug='recent-notices'),
subject='اعلامیه‌های مشتری', owner=case['creator'], selected_expert=case['creator'],
origin_visibility='SELECTED', owning_unit=case['unit'], last_activity_at=timezone.now())
notices = [services.issue_notice(case['creator'], {
'customer': case['customer'], 'account': case['account'], 'subject': f'اعلامیه اخیر {index}',
'amount': 12345, 'deposit_id': '000123',
}) for index in range(3)]
notices[-1].cancelled_at = timezone.now()
notices[-1].save(update_fields=['cancelled_at'])
other = User.objects.create_user(username='other-notice-customer', account_type='CUSTOMER')
foreign = services.issue_notice(case['creator'], {'customer': other, 'account': case['account'],
'subject': 'اعلامیه مشتری دیگر', 'amount': 100, 'deposit_id': '000123'})
receipt = submit(case)
# Only confirmed current receipt amounts count towards settlement.
PaymentReceipt.objects.filter(pk=receipt.pk).update(notice=notices[0], status='APPROVED')
client.force_login(case['creator'])
url = reverse('staff_tickets:detail', args=[ticket.public_id])
response = client.get(url)
assert response.status_code == 200
assert [notice.pk for notice in response.context['customer_notices']] == [n.pk for n in reversed(notices)]
html = response.content.decode()
assert case['notice'].number not in html
assert foreign.number not in html
assert 'لغو شده' in html and 'تسویه شده' in html and 'در انتظار واریز' in html
for notice in notices:
assert reverse('staff_payments:notice_detail', args=[notice.public_id]) in html
more = reverse('staff_payments:desk') + f'?tab=notices&customer={case["customer"].pk}'
assert more.replace('&', '&amp;') in html
assert client.get(more).context['page_obj'].paginator.count == 4
configuration = PaymentAccessConfiguration.objects.first()
configuration.open_view = False
configuration.save()
hidden = client.get(url)
assert not list(hidden.context['customer_notices'])
assert all(notice.number not in hidden.content.decode() for notice in notices)
def test_customer_notice_menu_is_ten_recent_pending_and_retains_old_link(case, client):
from apps.payments.models import PaymentNotice
for index in range(12):
services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': f'واریز {index}', 'amount': 20000, 'deposit_id': '123'})
cancelled = PaymentNotice.objects.filter(customer=case['customer']).first()
cancelled.cancelled_at = timezone.now()
cancelled.save()
client.force_login(case['customer'])
response = client.get(reverse('payments:receipt_new'))
choices = response.context['notice_data']
assert len(choices) == 10
assert str(cancelled.pk) not in {n['id'] for n in choices}
assert str(case['notice'].pk) not in {n['id'] for n in choices}
direct = client.get(reverse('payments:receipt_new'), {'notice': str(case['notice'].public_id)})
assert len(direct.context['notice_data']) == 10
assert str(case['notice'].pk) in {n['id'] for n in direct.context['notice_data']}
def test_linked_receipt_rejection_history_and_staff_identity(case, client):
from apps.customers.models import CustomerProfile
CustomerProfile.objects.update_or_create(user=case['customer'], defaults={
'first_name': 'مریم', 'last_name': 'امینی', 'province': 'اصفهان', 'city': 'گلپایگان'})
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE', 'مبلغ مطابقت دارد')
services.decide_receipt(case['second'], receipt.public_id, 1, 'REJECT', 'تصویر رسید خوانا نیست')
client.force_login(case['customer'])
overview = client.get(reverse('payments:notices'), {'notice': str(case['notice'].public_id)}).content.decode()
assert str(receipt.public_id) in overview and 'تصویر رسید خوانا نیست' in overview
detail = client.get(reverse('payments:receipt_detail', args=[receipt.public_id])).content.decode()
assert 'مبلغ مطابقت دارد' in detail and 'تصویر رسید خوانا نیست' in detail
assert 'افراد تعیین‌شده' not in detail and 'سوابق ارسال و اصلاح' in detail
client.force_login(case['second'])
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()
assert 'مریم' in listing and 'ایجادکننده اعلامیه: creator' in listing
def test_settled_notice_excluded_and_approvers_shown_on_staff_list(case, client):
from apps.payments.models import PaymentNotice
receipt = submit(case)
PaymentNotice.objects.filter(pk=case['notice'].pk).update(amount=12345)
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE', 'تطبیق مبلغ')
services.decide_receipt(case['second'], receipt.public_id, 1, 'APPROVE', 'تطبیق تصویر')
client.force_login(case['customer'])
response = client.get(reverse('payments:receipt_new'))
assert not response.context['notice_data']
client.force_login(case['first'])
for tab in ['receipts', 'notices']:
content = client.get(reverse('staff_payments:desk'), {'tab': tab}).content.decode()
assert 'first' in content and 'second' in content and 'creator' in content
number = receipt.number if tab == 'receipts' else case['notice'].number
found = client.get(reverse('staff_payments:desk'), {'tab': tab, 'q': number})
assert found.context['page_obj'].paginator.count == 1
settled = client.get(reverse('staff_payments:desk'), {'tab': 'notices', 'notice_state': 'settled'})
assert settled.context['page_obj'].paginator.count == 1
pending = client.get(reverse('staff_payments:desk'), {'tab': 'notices', 'notice_state': 'pending'})
assert pending.context['page_obj'].paginator.count == 0
def test_assigned_senior_can_reject_with_reason_and_unassigned_cannot(case, client):
receipt = submit(case)
client.force_login(case['senior'])
page = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])).content.decode()
assert 'value="REJECT"' in page and 'value="SENIOR_APPROVE"' in page
with pytest.raises(ValidationError):
services.decide_receipt(case['senior'], receipt.public_id, 1, 'REJECT', '')
grant(case['outsider'], 'senior_approve_paymentreceipt')
with pytest.raises(PermissionDenied):
services.decide_receipt(case['outsider'], receipt.public_id, 1, 'REJECT', 'توضیح')
services.decide_receipt(case['senior'], receipt.public_id, 1, 'REJECT', 'اطلاعات رسید با پرداخت مطابقت ندارد')
receipt.refresh_from_db()
assert receipt.status == 'REJECTED'
assert ReceiptDecision.objects.get(cycle__revision__receipt=receipt).actor_name == 'senior'
assert not NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').exists()
def test_customer_notice_has_independent_owned_detail_url(case, client):
receipt = submit(case)
url = reverse('payments:notice_detail', args=[case['notice'].public_id])
client.force_login(case['customer'])
listing = client.get(reverse('payments:notices')).content.decode()
assert url in listing
detail = client.get(url)
assert detail.status_code == 200
content = detail.content.decode()
assert 'توضیح همیشه نمایان' in content and str(receipt.public_id) in content
form = client.get(reverse('payments:receipt_new'), {'notice': str(case['notice'].public_id)})
assert form.context['notice_data'][0]['url'] == url
other = User.objects.create_user(username='other-notice-customer', account_type='CUSTOMER')
client.force_login(other)
assert client.get(url).status_code == 404
client.force_login(case['creator'])
assert client.get(url).status_code == 403
staff = client.get(reverse('staff_payments:receipt_new', args=[case['customer'].public_id]), {'notice': str(case['notice'].public_id)})
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')])
def test_deposit_identifier_reference_vectors(mobile, expected):
from apps.payments.deposit_identifier import generate_id_algorithm
assert generate_id_algorithm(mobile) == expected
def test_deposit_identifier_endpoint_uses_stored_mobile_and_permissions(case, client):
from django.test import Client
endpoint = reverse('staff_payments:generate_identifier')
client.force_login(case['creator'])
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['Cache-Control'] == 'no-store'
assert case['customer'].payment_notices.count() == before
assert client.get(endpoint).status_code == 405
assert client.post(endpoint, {'customer': case['outsider'].pk}).status_code == 400
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(case['creator'])
assert csrf_client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403
client.force_login(case['customer'])
assert client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403
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
+3 -3
View File
@@ -363,7 +363,7 @@ def test_root_redirects_to_login_or_authenticated_workspace(client):
username="root-customer", mobile="09120000991", account_type="CUSTOMER"
)
client.force_login(customer)
assert client.get("/").url == reverse("tickets:list")
assert client.get("/").url == reverse("payments:home")
staff = User.objects.create_user(
username="root-staff", mobile="09120000992", account_type="STAFF"
@@ -388,7 +388,7 @@ def test_customer_can_set_password_and_login(client):
assert (customer.customer_profile.province,customer.customer_profile.city)==("خراسان رضوی","مشهد")
client.logout()
response=client.post(reverse("accounts:password_login"),{"mobile":"09128888888","password":"strong-pass-123"})
assert response.status_code==302 and response.url==reverse("tickets:list")
assert response.status_code==302 and response.url==reverse("payments:home")
@pytest.mark.django_db
def test_magic_link_is_reusable_for_seven_days_and_only_grants_one_ticket(client):
@@ -595,7 +595,7 @@ def test_authenticated_users_are_redirected_away_from_all_login_pages(client):
client.force_login(customer)
for route in ("accounts:login","accounts:password_login","accounts:staff_login"):
response=client.get(reverse(route))
assert response.status_code==302 and response.url==reverse("tickets:list")
assert response.status_code==302 and response.url==reverse("payments:home")
staff=User.objects.create_user(username="already-staff",mobile="09384999655",account_type="STAFF")
StaffProfile.objects.create(user=staff,display_name="کارشناس واردشده")
client.force_login(staff)