add payment_link and banks
This commit is contained in:
1 parent
2ae79b1ca4
commit
de3135b588
100 files changed
+6167
-47
No files matched your search
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
@@ -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('فیش ثبت شد و در انتظار بررسی است.')
|
||||
@@ -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('تصمیم ثبت شد.')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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('&', '&') 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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user