estelam ticket, varizha , others

This commit is contained in:
javad committed 2026-10-13 15:48:24 +03:30
1 parent 35a1ec1794
commit 6b546aad76
71 files changed
+2865 -191

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
from pathlib import Path
from django.urls import reverse
from playwright.sync_api import expect
from tests.e2e.pages.base import BasePage
class DispatchWorkspacePage(BasePage):
@property
def review_dialog(self):
return self.page.get_by_role("dialog", name="بررسی نهایی ارسال گروهی")
def _activate(self, control, destination, *, hidden=False):
control.click()
if hidden:
expect(destination).to_be_hidden()
else:
expect(destination).to_be_visible()
def open_composer(self):
self.goto(reverse("staff_tickets:dispatch_create"))
self.page.get_by_role("tab", name="ارسال جدید").wait_for(state="visible")
def open_history(self):
self.page.get_by_role("tab", name="سوابق ارسال").click()
def return_to_composer(self):
self.page.get_by_role("tab", name="ارسال جدید").click()
def compose(self, unit, group, category, subject, body):
self.page.get_by_label("واحد ارسال‌کننده").select_option(str(unit.pk))
self.page.get_by_label("گروه مخاطبان", exact=False).select_option(str(group.pk))
self.page.get_by_label("دسته‌بندی", exact=False).select_option(str(category.pk))
self.page.get_by_label("موضوع استعلام یا پیام", exact=False).fill(subject)
self.page.get_by_label("متن پیام", exact=False).fill(body)
def review(self):
self._activate(self.page.get_by_role("button", name="بررسی و ادامه"), self.review_dialog)
def cancel_review(self):
self._activate(self.review_dialog.get_by_role("button", name="بازگشت و ویرایش"), self.review_dialog, hidden=True)
def send(self):
self._activate(
self.review_dialog.get_by_role("button", name="تأیید و ارسال گروهی"),
self.page.get_by_role("heading", name="وضعیت پیامک، مشاهده و پاسخ"),
)
def open_report(self, dispatch):
self.goto(reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id]))
def filter_recipients(self, label):
self.page.get_by_label("نمایش مخاطبان").select_option(label=label)
def search_recipient(self, text):
self.page.get_by_role("searchbox", name="جستجوی مخاطب").fill(text)
def reset_recipients(self):
self.page.get_by_role("button", name="پاک کردن فیلتر", exact=True).click()
def visible_recipients(self):
return self.page.locator("[data-recipient-row]:visible")
def open_conversation(self):
self.page.locator(".dispatch-reply-link").first.click()
def review_opening(self):
self._activate(self.page.get_by_role("link", name="بازگشایی همه پاسخ‌ها", exact=True),
self.page.get_by_role("heading", name="تأیید بازگشایی همه پاسخ‌ها"))
def confirm_opening(self):
self._activate(self.page.get_by_role("button", name="تأیید و بازگشایی همه پاسخ‌ها"),
self.page.get_by_role("heading", name="پاسخ‌ها بازگشایی شده‌اند"))
def choose_response_mode(self, label):
self.page.get_by_role("radio", name=label, exact=True).check()
@property
def opening_sms(self):
return self.page.get_by_role("checkbox", name="اطلاع‌رسانی بازگشایی با پیامک", exact=True)
def expect_fits_viewport(self):
assert self.page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
def save_screenshot(self, name):
directory = Path("test-results/dispatch-design")
directory.mkdir(parents=True, exist_ok=True)
self.page.screenshot(path=str(directory / name), full_page=True)
+7
View File
@@ -5,6 +5,13 @@ from .base import BasePage
class StaffPaymentsPage(BasePage):
@property
def daily_summary(self):
return self.page.get_by_role('region', name='وضعیت امروز')
def open_summary_definitions(self):
self.daily_summary.locator('summary').click()
def open_desk(self, parameters=''):
self.goto(reverse('staff_payments:desk') + parameters)
@@ -0,0 +1,105 @@
import pytest
from playwright.sync_api import expect
from apps.messaging.models import Message
from apps.notifications.models import SMSTemplate
from apps.tickets.services import dispatch_group_tickets
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.dispatches import DispatchWorkspacePage
from tests.e2e.test_dispatch_workspace_e2e import dispatch_case, PASSWORD
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize("width", [1440, 390, 320])
def test_sealed_report_requires_confirmation_and_opens_answers_together(live_server, browser_page, dispatch_case, width):
staff, unit, group, category, _, customers = dispatch_case
# Transactional live-server tests flush data migrations between tests.
SMSTemplate.objects.create(unit=unit, event_type="DISPATCH_RESPONSES_OPENED", title="بازگشایی", body="متن دقیق پیامک بازگشایی برای آزمون")
dispatch, tickets = dispatch_group_tickets(user=staff, unit=unit, contact_group=group,
category=category, subject="استعلام محرمانه مرورگر", body="پیشنهاد خود را ثبت کنید.")
answer = "پیشنهاد محرمانه مخاطب اول " * 12 + "پایان کامل پیشنهاد"
Message.objects.create(ticket=tickets[0], author=customers[0], body=answer)
second_answer = Message.objects.create(ticket=tickets[0], author=customers[0], body="توضیح تکمیلی پیشنهاد اول")
Message.objects.create(ticket=tickets[1], author=customers[1], body="پیشنهاد مخاطب دوم")
with browser_page() as page:
page.set_viewport_size({"width": width, "height": 900})
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
workspace = DispatchWorkspacePage(page, live_server.url)
workspace.open_report(dispatch)
expect(page.get_by_role("heading", name="پاسخ‌ها هنوز بازگشایی نشده‌اند")).to_be_visible()
expect(page.locator("body")).not_to_contain_text(answer)
expect(page.locator('[data-recipient-row] a[href*="/staff/tickets/"]')).to_have_count(0)
workspace.filter_recipients("دارای پاسخ")
expect(workspace.visible_recipients()).to_have_count(2)
expect(workspace.visible_recipients().first).to_contain_text("پاسخ داده است: بله")
workspace.expect_fits_viewport()
workspace.save_screenshot(f"sealed-report-{width}.png")
workspace.review_opening()
expect(page.locator(".opening-sms-preview")).to_have_text("متن دقیق پیامک بازگشایی برای آزمون")
expect(page.locator("body")).not_to_contain_text(answer)
workspace.expect_fits_viewport()
workspace.save_screenshot(f"opening-confirm-{width}.png")
workspace.confirm_opening()
expect(page.locator("body")).to_contain_text(answer)
expect(page.locator("body")).to_contain_text(second_answer.body)
expect(page.locator("body")).to_contain_text("پیشنهاد مخاطب دوم")
expect(page.locator(".recipient-reply")).to_have_count(3)
expect(page.locator('[data-recipient-row] a[href*="/staff/tickets/"]')).to_have_count(0)
from apps.core.templatetags.jalali import jalali_datetime_precise
expect(page.locator(".recipient-reply time").nth(1)).to_have_text(jalali_datetime_precise(second_answer.created_at))
workspace.expect_fits_viewport()
workspace.save_screenshot(f"opened-report-{width}.png")
def test_composer_defaults_to_sealed_and_disables_sms_in_individual_mode(live_server, browser_page, dispatch_case):
staff, unit, group, category, _, _ = dispatch_case
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
workspace = DispatchWorkspacePage(page, live_server.url)
workspace.open_composer()
workspace.compose(unit, group, category, "پیش‌فرض پاسخ محرمانه", "متن درخواست")
expect(page.get_by_role("radio", name="بازگشایی یکجا", exact=True)).to_be_checked()
expect(workspace.opening_sms).to_be_checked()
workspace.review()
expect(workspace.review_dialog.locator('[data-review-inquiry-recommendation]')).to_be_hidden()
workspace.cancel_review()
workspace.choose_response_mode("مشاهده جداگانه")
expect(workspace.opening_sms).to_be_disabled()
expect(workspace.opening_sms).not_to_be_checked()
workspace.review()
expect(workspace.review_dialog).to_contain_text("مشاهده جداگانه؛ پیامک بازگشایی: غیرفعال")
expect(workspace.review_dialog.locator('[data-review-inquiry-recommendation]')).to_be_visible()
expect(workspace.review_dialog).to_contain_text("برای تمامی استعلام‌گیری‌ها توصیه می‌کنیم")
workspace.cancel_review()
workspace.choose_response_mode("بازگشایی یکجا")
expect(workspace.opening_sms).to_be_checked()
workspace.opening_sms.uncheck()
workspace.choose_response_mode("مشاهده جداگانه")
workspace.choose_response_mode("بازگشایی یکجا")
expect(workspace.opening_sms).not_to_be_checked()
workspace.review()
expect(workspace.review_dialog.locator('[data-review-inquiry-recommendation]')).to_be_visible()
workspace.save_screenshot("review-inquiry-recommendation.png")
@pytest.mark.parametrize("count", [15, 16])
def test_composer_enforces_opening_sms_recipient_cap(live_server, browser_page, dispatch_case, count):
from tests.test_organizational_ticketing import make_contact
staff, unit, group, category, _, _ = dispatch_case
for i in range(2, count):
make_contact(f"browser-limit-{i}", f"09122100{i:03}").customer_profile.groups.add(group)
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
workspace = DispatchWorkspacePage(page, live_server.url)
workspace.open_composer()
workspace.compose(unit, group, category, "آزمون سقف پیامک مرورگر", "متن استعلام")
if count == 15:
expect(workspace.opening_sms).to_be_enabled()
expect(workspace.opening_sms).to_be_checked()
else:
expect(workspace.opening_sms).to_be_disabled()
expect(workspace.opening_sms).not_to_be_checked()
expect(page.locator('[data-opening-sms-hint]')).to_contain_text("بیش از ۱۵")
workspace.review()
expect(workspace.review_dialog.locator('[data-review-inquiry-limit]')).to_be_visible()
+108
View File
@@ -0,0 +1,108 @@
import pytest
from django.contrib.auth.models import Permission
from django.urls import reverse
from django.utils import timezone
from playwright.sync_api import expect
from apps.accounts.models import TicketMagicLink, User
from apps.customers.models import ContactGroup, CustomerProfile
from apps.messaging.models import Message
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_group_dispatch
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.dispatches import DispatchWorkspacePage
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
PASSWORD = "Dispatch-E2E-safe-2026"
@pytest.fixture
def dispatch_case():
unit = OrganizationalUnit.objects.create(name="فروش رابط گروهی", slug="dispatch-ui")
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=("view_ticket", "change_ticket", "dispatch_group_tickets")))
staff = User.objects.create_user(username="dispatch-ui-agent", password=PASSWORD, account_type="STAFF")
StaffProfile.objects.create(user=staff, display_name="کارشناس ارسال", primary_unit=unit)
group = ContactGroup.objects.create(unit=unit, name="مخاطبان منتخب فروش")
empty_group = ContactGroup.objects.create(unit=unit, name="گروه بدون مخاطب")
category = TicketCategory.objects.create(unit=unit, title="هماهنگی فروش", slug="dispatch-ui-category")
customers = []
for index, name in enumerate(("مخاطب پاسخ‌دهنده", "مخاطب بدون پاسخ")):
customer = User.objects.create_user(username=f"dispatch-ui-customer-{index}", mobile=f"0912000980{index}", account_type="CUSTOMER")
CustomerProfile.objects.create(user=customer, first_name=name).groups.add(group)
customers.append(customer)
return staff, unit, group, category, empty_group, customers
@pytest.mark.parametrize("width", [1440, 390, 320])
def test_review_preserves_draft_and_requires_confirmation_before_group_send(live_server, browser_page, dispatch_case, width):
staff, unit, group, category, _, _ = dispatch_case
subject = "هماهنگی زمان تحویل گروهی"
body = "لطفاً زمان پیشنهادی تحویل را در گفت‌وگوی خصوصی اعلام کنید."
with browser_page() as page:
page.set_viewport_size({"width": width, "height": 900 if width == 1440 else 844})
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
workspace = DispatchWorkspacePage(page, live_server.url)
workspace.open_composer()
workspace.compose(unit, group, category, subject, body)
expect(page.get_by_label("خلاصه ارسال جدید")).to_contain_text("۲ نفر")
workspace.open_history()
expect(page.get_by_text("هنوز ارسال گروهی ثبت نشده است")).to_be_visible()
workspace.return_to_composer()
expect(page.get_by_label("متن پیام", exact=False)).to_have_value(body)
workspace.review()
dialog = page.get_by_role("dialog")
expect(dialog).to_contain_text("۲ نفر")
expect(dialog).to_contain_text(subject)
workspace.cancel_review()
expect(page.get_by_role("button", name="بررسی و ادامه")).to_be_enabled()
workspace.save_screenshot(f"compose-{width}.png")
workspace.review()
workspace.save_screenshot(f"review-{width}.png")
workspace.send()
expect(page.locator("[data-recipient-row]")).to_have_count(2)
expect(page.locator(".sms-state-card").first).to_contain_text("در صف ارسال")
expect(page.get_by_text("تحویل پیامک به معنی مشاهده لینک یا دریافت پاسخ نیست.")).to_be_visible()
workspace.expect_fits_viewport()
@pytest.mark.parametrize("width", [1440, 390, 320])
def test_report_filters_search_and_private_reply_navigation(live_server, browser_page, dispatch_case, width):
staff, unit, group, category, _, customers = dispatch_case
dispatch, tickets = create_group_dispatch(user=staff, unit=unit, contact_group=group, category=category, subject="پیگیری پاسخ مخاطبان منتخب", body="پیام اصلی با پاسخ‌های خصوصی مستقل.", response_mode="INDIVIDUAL")
first = tickets[0]
Message.objects.create(ticket=first, author=customers[0], body="زمان پیشنهادی ما ساعت ده صبح است.")
first.last_customer_message_at = timezone.now()
first.save(update_fields=["last_customer_message_at"])
link = TicketMagicLink.issue_with_record(first, created_by=staff)[1]
link.view_count = 2
link.save(update_fields=["view_count"])
NotificationJob.objects.filter(ticket=first, event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).update(status="SENT", attempts=1, delivery_status="DELIVERED")
NotificationJob.objects.filter(ticket=tickets[1], event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).update(status="FAILED", attempts=1, last_error="خطای آزمایشی درگاه")
with browser_page() as page:
page.set_viewport_size({"width": width, "height": 900 if width == 1440 else 844})
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
workspace = DispatchWorkspacePage(page, live_server.url)
workspace.open_report(dispatch)
expect(workspace.visible_recipients()).to_have_count(2)
workspace.filter_recipients("دارای پاسخ")
expect(workspace.visible_recipients()).to_have_count(1)
expect(workspace.visible_recipients()).to_contain_text("زمان پیشنهادی ما ساعت ده صبح است.")
workspace.reset_recipients()
workspace.filter_recipients("پیامک نیازمند اقدام")
expect(workspace.visible_recipients()).to_have_count(1)
expect(workspace.visible_recipients()).to_contain_text("ارسال ناموفق")
# One failed attempt is not a successful first attempt.
expect(workspace.visible_recipients()).not_to_contain_text("موفق در اولین تلاش")
workspace.reset_recipients()
workspace.search_recipient("۰۹۱۲۰۰۰۹۸۰۰")
expect(workspace.visible_recipients()).to_have_count(1)
workspace.search_recipient("وجود ندارد")
expect(page.get_by_role("heading", name="مخاطبی با این شرایط پیدا نشد")).to_be_visible()
workspace.reset_recipients()
workspace.expect_fits_viewport()
workspace.save_screenshot(f"report-{width}.png")
workspace.filter_recipients("دارای پاسخ")
workspace.open_conversation()
expect(page).to_have_url(workspace.url(reverse("staff_tickets:detail", args=[first.public_id])))
@@ -0,0 +1,61 @@
import uuid
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from playwright.sync_api import expect
from apps.payments import services
from apps.payments.models import MAX_AMOUNT
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.staff_payments import StaffPaymentsPage
from tests.e2e.test_payments_e2e import PASSWORD, PNG, payment_case
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
def test_daily_summary_mobile_desktop_and_filtered_empty_results(live_server, browser_page, payment_case, tmp_path):
customer, account, reviewers = payment_case
services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
'amount': 100000, 'subject': 'اعلامیه آزمایشی', 'description': '', 'deposit_id': ''})
receipt = services.submit_receipt(customer, customer, {
'paid_amount': MAX_AMOUNT, 'paid_date': timezone.localdate(),
'destination_account': account, 'depositor_name': 'مشتری آزمایشی',
'submission_key': uuid.uuid4(),
'receipt_image': SimpleUploadedFile('receipt.png', PNG, content_type='image/png'),
})
services.decide_receipt(reviewers[0], receipt.public_id, 1, 'APPROVE')
services.decide_receipt(reviewers[1], receipt.public_id, 1, 'APPROVE')
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
workspace = StaffPaymentsPage(page, live_server.url)
workspace.open_desk()
summary = workspace.daily_summary
expect(summary).to_be_visible()
expect(summary.locator('[data-summary=submitted]')).to_contain_text('1 فیش')
expect(summary.locator('[data-summary=approved]')).to_contain_text('1 فیش')
expect(summary.locator('[data-summary=rejected]')).to_contain_text('0 فیش')
expect(summary.locator('[data-summary=pending]')).to_contain_text('0 فیش')
expect(summary.locator('[data-summary=notices]')).to_contain_text('1 اعلامیه')
amount = f'{MAX_AMOUNT:,}'
expect(summary.locator('[data-summary=approved]')).to_contain_text(amount)
for width, height in [(320, 568), (360, 640), (390, 844), (768, 1024), (1440, 900)]:
page.set_viewport_size({'width': width, 'height': height})
expect(summary).to_be_visible()
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
assert summary.evaluate('(el) => el.scrollWidth <= el.clientWidth + 1')
page.screenshot(path=tmp_path / f'payment-summary-{width}.png', full_page=True)
page.set_viewport_size({'width': 320, 'height': 568})
page.evaluate("document.documentElement.style.fontSize = '200%'")
assert summary.evaluate('(el) => el.scrollWidth <= el.clientWidth + 1')
expect(summary.locator('[data-summary=approved]')).to_contain_text(amount)
workspace.open_summary_definitions()
definitions = summary.locator('.pay-day-definitions')
for title in ['ارسالی امروز:', 'تأیید امروز:', 'رد امروز:', 'در انتظار بررسی:', 'اعلامیه امروز:', 'قواعد مشترک:']:
expect(definitions.get_by_text(title, exact=True)).to_be_visible()
expect(definitions).not_to_contain_text('امروز بر اساس ساعت تهران است')
page.evaluate("document.documentElement.style.fontSize = ''")
workspace.search('no-such-customer')
expect(page.get_by_text('موردی پیدا نشد', exact=True)).to_be_visible()
expect(summary.locator('[data-summary=approved]')).to_contain_text('1 فیش')
expect(summary.locator('[data-summary=approved]')).to_contain_text(amount)
+4
View File
@@ -16,6 +16,7 @@ from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket, dispatch_group_tickets
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.dispatches import DispatchWorkspacePage
from tests.e2e.pages.tickets import TicketConversationPage
@@ -127,7 +128,10 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
expect(page).to_have_title("ردیابی E2E")
expect(page.locator(".staff-brand-copy b")).to_have_text("مرکز عملیات E2E")
expect(page.locator(".staff-developer-credit")).to_contain_text("امضای مستقل sidebar E2E")
workspace = DispatchWorkspacePage(page, live_server.url)
workspace.open_history()
expect(page.get_by_role("searchbox", name="جستجو")).to_be_visible()
workspace.return_to_composer()
expect(page.locator('link[href*="dispatches.css"]')).to_have_count(1)
expect(page.get_by_text("افزودن عکس از گالری")).to_be_visible()
expect(page.locator('label[for="dispatch-file-input"]')).to_be_visible()
+103
View File
@@ -0,0 +1,103 @@
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
import pytest
from django.db import connection, connections
from django.db.migrations.executor import MigrationExecutor
from apps.accounts.models import User
from apps.messaging.models import Message
from apps.notifications.dispatch_opening import ensure_opening_template
from apps.notifications.models import NotificationJob
from apps.tickets.dispatch_responses import OPENING_EVENT, open_dispatch_responses, render_opening_sms
from apps.tickets.models import DispatchResponseControl, GroupDispatch
from apps.tickets.services import reply_ticket
from tests.test_dispatch_response_opening import opening_case
pytestmark = pytest.mark.django_db(transaction=True)
def _postgres_only():
if connection.vendor != "postgresql":
pytest.skip("Real row-lock concurrency requires PostgreSQL")
def test_two_simultaneous_openings_commit_one_event_and_one_outbox_job(opening_case):
_postgres_only()
c = opening_case
ensure_opening_template()
text = render_opening_sms(c["dispatch"])
barrier = Barrier(2)
def open_once(user_id):
connections.close_all()
try:
user = User.objects.get(pk=user_id)
dispatch = GroupDispatch.objects.get(pk=c["dispatch"].pk)
barrier.wait(timeout=10)
return open_dispatch_responses(dispatch, user, text)[1]
finally:
connections.close_all()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(open_once, c[actor].pk) for actor in ("staff", "admin")]
assert sorted(future.result(timeout=20) for future in futures) == [False, True]
assert NotificationJob.objects.filter(event_type=OPENING_EVENT).count() == 1
assert DispatchResponseControl.objects.get(dispatch=c["dispatch"]).opened_at
def test_reply_racing_opening_matches_the_atomic_recipient_snapshot(opening_case):
_postgres_only()
c = opening_case
ensure_opening_template()
text = render_opening_sms(c["dispatch"])
barrier = Barrier(2)
def run(action):
connections.close_all()
try:
barrier.wait(timeout=10)
if action == "reply":
return reply_ticket(ticket_id=c["tickets"][1].pk, user=User.objects.get(pk=c["customers"][1].pk), body="پاسخ هم‌زمان")
return open_dispatch_responses(c["dispatch"], User.objects.get(pk=c["staff"].pk), text)
finally:
connections.close_all()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(run, action) for action in ("reply", "open")]
for future in futures:
future.result(timeout=20)
control = DispatchResponseControl.objects.get(dispatch=c["dispatch"])
expected = set(Message.objects.filter(ticket__group_dispatch=c["dispatch"], author_id__in=[u.pk for u in c["customers"]], created_at__lte=control.opened_at).values_list("author_id", flat=True))
assert set(control.respondents_snapshot) == {str(value) for value in User.objects.filter(pk__in=expected).values_list("public_id", flat=True)}
assert set(NotificationJob.objects.filter(event_type=OPENING_EVENT).values_list("customer_id", flat=True)) == expected
def test_additive_migrations_preserve_existing_rows_and_legacy_response_visibility(opening_case):
c = opening_case
# Turn this fixture into a pre-feature dispatch before moving the test schema back.
DispatchResponseControl.objects.filter(dispatch=c["dispatch"]).delete()
before_dispatch = list(GroupDispatch.objects.values())
before_messages = list(Message.objects.values())
before_users = list(User.objects.values())
old_targets = [("tickets", "0017_ticketaccessgrant_first_viewed_at_and_more"), ("notifications", "0020_receipt_rejection_template")]
executor = MigrationExecutor(connection)
current_targets = executor.loader.graph.leaf_nodes()
try:
executor.migrate(old_targets)
MigrationExecutor(connection).migrate(current_targets)
assert list(GroupDispatch.objects.values()) == before_dispatch
assert list(Message.objects.values()) == before_messages
assert list(User.objects.values()) == before_users
assert not DispatchResponseControl.objects.filter(dispatch=c["dispatch"]).exists()
from apps.tickets.staff_conversation import visible_messages
assert visible_messages(c["tickets"][0], c["staff"]).filter(pk=c["message"].pk).exists()
finally:
MigrationExecutor(connection).migrate(current_targets)
def test_migration_refuses_to_drop_response_confidentiality_after_use(opening_case):
from django.apps import apps
from django.db.migrations.exceptions import IrreversibleError
from types import SimpleNamespace
import importlib
guard = importlib.import_module("apps.tickets.migrations.0018_dispatch_response_opening").preserve_used_response_controls
with pytest.raises(IrreversibleError):
guard(apps, SimpleNamespace(connection=connection))
assert DispatchResponseControl.objects.filter(dispatch=opening_case["dispatch"]).exists()
+46
View File
@@ -0,0 +1,46 @@
import pytest
from django.contrib.auth.models import Permission
from django.urls import reverse
from apps.accounts.models import User
from apps.customers.models import ContactGroup, CustomerProfile
from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.dispatch_presentation import dispatch_form_options
from apps.tickets.models import TicketCategory
pytestmark = pytest.mark.django_db
def test_dispatch_options_count_only_eligible_members_and_preserve_unit_scope(client):
unit = OrganizationalUnit.objects.create(name="واحد مجاز", slug="dispatch-options")
other = OrganizationalUnit.objects.create(name="واحد دیگر", slug="dispatch-options-other")
unit.permissions.add(Permission.objects.get(codename="dispatch_group_tickets"))
user = User.objects.create_user(username="dispatch-options", account_type="STAFF")
StaffProfile.objects.create(user=user, primary_unit=unit)
group = ContactGroup.objects.create(unit=unit, name="گروه مجاز")
hidden_group = ContactGroup.objects.create(unit=other, name="گروه پنهان")
category = TicketCategory.objects.create(unit=unit, title="مجاز", slug="options-category")
hidden_category = TicketCategory.objects.create(unit=other, title="پنهان", slug="options-hidden-category")
for index, (mobile, active) in enumerate([("09120000091", True), ("", True), (None, True), ("09120000092", False)]):
contact = User.objects.create_user(username=f"options-customer-{index}", mobile=mobile, is_active=active, account_type="CUSTOMER")
profile = CustomerProfile.objects.create(user=contact)
profile.groups.add(group)
client.force_login(user)
response = client.get(reverse("staff_tickets:dispatch_create"))
assert response.status_code == 200
options = response.context["dispatch_options"]
assert options["units"] == [{"id": unit.pk, "name": unit.name, "operations": ["BULK"]}]
assert options["groups"] == [{"id": group.pk, "name": group.name, "unit_id": unit.pk, "recipient_count": 1}]
assert category.pk in [item["id"] for item in options["categories"]]
assert hidden_category.pk not in [item["id"] for item in options["categories"]]
assert hidden_group.pk not in [item["id"] for item in options["groups"]]
assert "?v=" in response.content.decode().split("dispatch-workspace.js", 1)[1]
def test_dispatch_options_include_global_category_fallback_for_units_without_categories():
unit = OrganizationalUnit.objects.create(name="بدون دسته‌بندی", slug="dispatch-options-fallback")
user = User.objects.create_superuser(username="options-admin", account_type="STAFF")
global_category = TicketCategory.objects.create(title="عمومی", slug="options-global", unit=None)
options = dispatch_form_options(user, [unit], ContactGroup.objects.none())
assert {"id": global_category.pk, "title": global_category.title, "unit_id": None} in options["categories"]
assert options["groups"] == []
+368
View File
@@ -0,0 +1,368 @@
import importlib
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.db import IntegrityError, transaction
from django.test import RequestFactory
from django.urls import reverse
from apps.accounts.models import User
from apps.attachments.models import Attachment, AttachmentPolicy
from apps.messaging.models import Message, MessageReadReceipt, MessageRevision
from apps.messaging.services import edit_message
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.notifications.management.commands.process_notifications import _send_bale, _send_sms
from apps.staff.models import OrganizationalUnit
from apps.tickets.dispatch_responses import OPENING_EVENT, open_dispatch_responses, render_opening_sms
from apps.tickets.models import DispatchResponseControl, GroupDispatch, MessageShare, TicketCategory
from apps.tickets.policies import TicketPolicy
from apps.tickets.services import create_group_dispatch, dispatch_group_tickets, reply_ticket
from apps.tickets.sharing import share_staff_message
from tests.test_organizational_ticketing import make_contact, make_staff
pytestmark = pytest.mark.django_db
@pytest.fixture
def opening_case():
unit = OrganizationalUnit.objects.create(name="واحد بازگشایی", slug="opening-unit", bale_chat_id="opening-room")
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=["view_ticket", "create_inquiry", "create_bulk_dispatch"]))
staff = make_staff("opening-owner", unit)
other = make_staff("opening-peer", unit)
viewer = make_staff("opening-viewer", unit)
viewer.user_permissions.add(Permission.objects.get(codename="view_all_bulk_dispatches"))
admin = User.objects.create_superuser(username="opening-admin", password="Admin-test-91", account_type="STAFF")
from apps.customers.models import ContactGroup
group = ContactGroup.objects.create(name="گروه استعلام", unit=unit)
customers = [make_contact(f"opening-contact-{i}", f"0912200010{i}") for i in range(2)]
for customer in customers:
customer.customer_profile.groups.add(group)
category = TicketCategory.objects.create(unit=unit, title="استعلام", slug="opening-category")
dispatch, tickets = dispatch_group_tickets(user=staff, unit=unit, contact_group=group, category=category,
subject="استعلام قیمت محرمانه", body="لطفاً پیشنهاد خود را ثبت کنید.")
message = reply_ticket(ticket_id=tickets[0].pk, user=customers[0], body="SECRET_BID_84723")
return dict(unit=unit, staff=staff, other=other, viewer=viewer, admin=admin, group=group,
customers=customers, category=category, dispatch=dispatch, tickets=tickets, message=message)
@pytest.mark.parametrize("actor", ["staff", "viewer", "admin"])
def test_seal_blocks_report_ticket_poll_revisions_and_receipts(client, opening_case, actor):
c = opening_case
client.force_login(c[actor])
for route, public_id in [("dispatch_detail", c["dispatch"].public_id), ("detail", c["tickets"][0].public_id),
("messages", c["tickets"][0].public_id), ("poll", c["tickets"][0].public_id)]:
response = client.get(reverse("staff_tickets:" + route, args=[public_id]))
assert response.status_code == 200
assert c["message"].body not in response.content.decode()
report = client.get(reverse("staff_tickets:dispatch_detail", args=[c["dispatch"].public_id]))
assert report.context["metrics"]["replied"] == 1
assert 'data-answered="true"' in report.content.decode()
assert 'پاسخ داده است: بله' in report.content.decode()
assert client.get(reverse("messages:revisions", args=[c["message"].public_id])).status_code == 403
client.post(reverse("staff_tickets:read_shares", args=[c["tickets"][0].public_id]), {"message_ids": [str(c["message"].public_id)]})
assert not MessageReadReceipt.objects.filter(message=c["message"]).exists()
def test_unit_wide_access_does_not_grant_unrelated_managed_dispatch_tickets(client, opening_case):
c = opening_case
assert not TicketPolicy.can_view(c["other"], c["tickets"][0])
assert not c["tickets"][0].__class__.objects.filter(TicketPolicy.viewable_q(c["other"]), pk=c["tickets"][0].pk).exists()
client.force_login(c["other"])
assert client.get(reverse("staff_tickets:detail", args=[c["tickets"][0].public_id])).status_code == 403
c["tickets"][0].owner = c["other"]
c["tickets"][0].save()
assert TicketPolicy.can_view(c["other"], c["tickets"][0])
response = client.get(reverse("staff_tickets:detail", args=[c["tickets"][0].public_id]))
assert response.status_code == 200 and c["message"].body not in response.content.decode()
@pytest.mark.parametrize("mode", ["SEALED", "INDIVIDUAL", "LEGACY"])
def test_report_shows_full_response_history_and_files_without_ticket_navigation(client, opening_case, mode):
from apps.core.templatetags.jalali import jalali_datetime_precise
c = opening_case
control = c["dispatch"].response_control
if mode == "LEGACY":
control.delete()
elif mode == "INDIVIDUAL":
control.mode, control.notify_sms = mode, False
control.save()
body = "متن کامل پیشنهاد " * 30 + "پایان متن بلند"
answer = reply_ticket(ticket_id=c["tickets"][0].pk, user=c["customers"][0], body=body)
AttachmentPolicy.objects.create(attachment_type="PDF", max_file_size=1024 * 1024,
allowed_extensions=[".pdf"], allowed_mime_types=["application/pdf"])
from apps.attachments.services import save_attachments
save_attachments(answer, [SimpleUploadedFile("offer.pdf", b"%PDF-1.4\n%%EOF", content_type="application/pdf")], c["customers"][0])
file = answer.attachments.get()
deleted = reply_ticket(ticket_id=c["tickets"][0].pk, user=c["customers"][0], body="DELETED_OFFER")
from django.utils import timezone
deleted.deleted_at = timezone.now()
deleted.save(update_fields=["deleted_at"])
client.force_login(c["staff"])
url = reverse("staff_tickets:dispatch_detail", args=[c["dispatch"].public_id])
ticket_url = reverse("staff_tickets:detail", args=[c["tickets"][0].public_id])
attachment_url = reverse("attachments:view", args=[file.public_id])
if mode == "SEALED":
locked = client.get(url)
assert body not in locked.content.decode() and attachment_url not in locked.content.decode()
assert not locked.context["tickets"][0].report_customer_responses
open_dispatch_responses(c["dispatch"], c["staff"], render_opening_sms(c["dispatch"]))
report = client.get(url)
html = report.content.decode()
assert body in html and c["message"].body in html and deleted.body not in html
assert attachment_url in html and jalali_datetime_precise(answer.created_at) in html
assert (f'href="{ticket_url}"' in html) == (mode != "SEALED")
def test_customer_can_read_and_edit_own_sealed_answer(client, opening_case):
c = opening_case
client.force_login(c["customers"][0])
response = client.get(reverse("tickets:detail", args=[c["tickets"][0].public_id]))
assert c["message"].body in response.content.decode()
edit_message(c["message"].pk, c["customers"][0], "SECRET_EDITED_BID")
client.force_login(c["admin"])
assert client.get(reverse("messages:revisions", args=[c["message"].public_id])).status_code == 403
def test_opening_is_once_and_uses_confirmed_template_snapshot(opening_case, monkeypatch):
c = opening_case
text = render_opening_sms(c["dispatch"])
control, opened = open_dispatch_responses(c["dispatch"], c["staff"], text)
assert opened and control.opened_by == c["staff"] and control.opened_at
assert control.respondents_snapshot == [str(c["customers"][0].public_id)]
job = NotificationJob.objects.get(event_type=OPENING_EVENT)
SMSTemplate.objects.filter(event_type=OPENING_EVENT).update(body="متن تغییر یافته")
assert open_dispatch_responses(c["dispatch"], c["admin"], text)[1] is False
assert NotificationJob.objects.filter(event_type=OPENING_EVENT).count() == 1
sent = []
class Provider:
def send_message(self, mobile, body):
sent.append((mobile, body))
return "test-provider-id"
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: Provider())
assert _send_sms(job) == ("test-provider-id", None)
assert sent == [(c["customers"][0].mobile, text)]
reply_ticket(ticket_id=c["tickets"][1].pk, user=c["customers"][1], body="پاسخ پس از بازگشایی")
assert NotificationJob.objects.filter(event_type=OPENING_EVENT).count() == 1
def test_template_change_requires_new_confirmation_and_view_permission_is_not_open_permission(client, opening_case):
c = opening_case
with pytest.raises(PermissionDenied):
open_dispatch_responses(c["dispatch"], c["viewer"], render_opening_sms(c["dispatch"]))
c["viewer"].user_permissions.add(Permission.objects.get(codename="open_all_dispatch_responses"))
viewer = User.objects.get(pk=c["viewer"].pk)
client.force_login(viewer)
url = reverse("staff_tickets:dispatch_open_responses", args=[c["dispatch"].public_id])
assert client.post(url).status_code == 200
proof = client.get(url).context["confirmation"]
SMSTemplate.objects.filter(event_type=OPENING_EVENT).update(body="متن جدید بازگشایی")
client.post(url, {"confirmation": proof})
assert not DispatchResponseControl.objects.get(dispatch=c["dispatch"]).opened_at
proof = client.get(url).context["confirmation"]
assert client.post(url, {"confirmation": proof}).status_code == 302
assert DispatchResponseControl.objects.get(dispatch=c["dispatch"]).opened_by == viewer
@pytest.mark.parametrize("count,mode,allowed", [(15, "SEALED", True), (16, "SEALED", False), (2, "INDIVIDUAL", False)])
def test_sms_recipient_limit_is_frozen_and_individual_mode_disables_it(opening_case, count, mode, allowed):
c = opening_case
for i in range(2, count):
make_contact(f"limit-{i}", f"09122000{i:03}").customer_profile.groups.add(c["group"])
dispatch, tickets = create_group_dispatch(user=c["staff"], unit=c["unit"], contact_group=c["group"], category=c["category"],
subject="آزمون سقف مخاطبان", body="متن استعلام", response_mode=mode, notify_opening_sms=True)
control = DispatchResponseControl.objects.get(dispatch=dispatch)
assert control.recipient_count_snapshot == count and control.notify_sms is allowed
if count == 16:
c["group"].contacts.clear()
with transaction.atomic(), pytest.raises(IntegrityError):
DispatchResponseControl.objects.filter(pk=control.pk).update(notify_sms=True)
if mode == "INDIVIDUAL":
message = reply_ticket(ticket_id=tickets[0].pk, user=c["customers"][0], body="پاسخ جداگانه")
from apps.tickets.staff_conversation import visible_messages
assert visible_messages(tickets[0], c["staff"]).filter(pk=message.pk).exists()
def test_sealed_attachment_shared_routes_admin_and_direct_media_are_blocked(client, opening_case, settings, tmp_path):
c = opening_case
settings.MEDIA_ROOT = tmp_path
settings.USE_NGINX_MEDIA = True
from apps.attachments.services import save_attachments
AttachmentPolicy.objects.create(attachment_type="PDF", max_file_size=1024 * 1024,
allowed_extensions=[".pdf"], allowed_mime_types=["application/pdf"])
save_attachments(c["message"], [SimpleUploadedFile("secret.pdf", b"%PDF-1.4\n%%EOF", content_type="application/pdf")], c["customers"][0])
attachment = Attachment.objects.get(message=c["message"])
assert attachment.relative_path.startswith("dispatch-responses/")
MessageRevision.objects.create(message=c["message"], body="OLD_SECRET_BID", edited_by=c["customers"][0])
with pytest.raises(PermissionDenied):
share_staff_message(c["message"], c["staff"], c["other"])
grant = MessageShare.objects.create(message=c["message"], user=c["other"], granted_by=c["staff"])
client.force_login(c["other"])
assert client.get(reverse("staff_tickets:shared_message_attachment", args=[grant.public_id, attachment.public_id])).status_code == 403
client.force_login(c["admin"])
for route in ("view", "download"):
assert client.get(reverse("attachments:" + route, args=[attachment.public_id])).status_code == 403
for name, obj in [("messaging_message", c["message"]), ("messaging_messagerevision", c["message"].revisions.get()), ("attachments_attachment", attachment)]:
response = client.get(reverse("admin:" + name + "_change", args=[obj.pk]), follow=True)
assert response.redirect_chain and c["message"].body not in response.content.decode()
open_dispatch_responses(c["dispatch"], c["admin"], render_opening_sms(c["dispatch"]))
assert client.get(reverse("admin:messaging_message_change", args=[c["message"].pk])).status_code == 200
client.force_login(c["customers"][0])
response = client.get(reverse("attachments:view", args=[attachment.public_id]))
assert response.status_code == 200 and "no-store" in response["Cache-Control"]
assert b"".join(response.streaming_content).startswith(b"%PDF")
from config.urls import debug_media
from django.http import Http404
with pytest.raises(Http404):
debug_media(RequestFactory().get("/media/"), attachment.relative_path)
def test_bale_does_not_disclose_managed_response_text(opening_case, monkeypatch):
c = opening_case
profile = c["staff"].staff_profile
profile.bale_chat_id = "private-dispatch-owner"
profile.save(update_fields=["bale_chat_id"])
sent = []
class Provider:
def send_message(self, chat_id, body):
sent.append((chat_id, body))
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_bale_provider", lambda: Provider())
job = NotificationJob.objects.get(event_type="CUSTOMER_TICKET_REPLY", ticket=c["tickets"][0])
_send_bale(job)
assert sent and sent[0][0] == "private-dispatch-owner" and c["message"].body not in sent[0][1]
def test_seed_only_adds_missing_opening_template_and_preserves_customizations():
from django.apps import apps
from django.db import connection
migration = importlib.import_module("apps.notifications.migrations.0021_dispatch_response_opening")
original = list(SMSTemplate.objects.exclude(event_type=OPENING_EVENT).values())
SMSTemplate.objects.filter(event_type=OPENING_EVENT).update(body="قالب اختصاصی حفظ شود")
from types import SimpleNamespace
schema_editor = SimpleNamespace(connection=connection)
migration.seed_opening_template(apps, schema_editor)
migration.seed_opening_template(apps, schema_editor)
assert SMSTemplate.objects.get(event_type=OPENING_EVENT).body == "قالب اختصاصی حفظ شود"
assert list(SMSTemplate.objects.exclude(event_type=OPENING_EVENT).values()) == original
def test_view_only_staff_can_list_reports_without_creation_permission(client, opening_case):
c = opening_case
unit = OrganizationalUnit.objects.create(name="واحد مشاهده", slug="opening-observer")
viewer = make_staff("only-view-dispatches", unit)
viewer.user_permissions.add(Permission.objects.get(codename="view_all_bulk_dispatches"))
client.force_login(viewer)
response = client.get(reverse("staff_tickets:dispatch_create"))
assert response.status_code == 200 and c["dispatch"].subject in response.content.decode()
assert response.context["staff_operations_access"]["dispatches"] is True
assert 'id="dispatch-compose-form"' not in response.content.decode()
assert client.post(reverse("staff_tickets:dispatch_create"), {}).status_code == 403
assert client.get(reverse("staff_tickets:dispatch_open_responses", args=[c["dispatch"].public_id])).status_code == 403
def test_opening_rolls_back_if_outbox_cannot_be_saved(opening_case, monkeypatch):
c = opening_case
def fail(*args, **kwargs):
raise RuntimeError("outbox unavailable")
monkeypatch.setattr("apps.notifications.services.enqueue", fail)
with pytest.raises(RuntimeError):
open_dispatch_responses(c["dispatch"], c["staff"], render_opening_sms(c["dispatch"]))
assert not DispatchResponseControl.objects.get(dispatch=c["dispatch"]).opened_at
assert not NotificationJob.objects.filter(event_type=OPENING_EVENT).exists()
def test_existing_worker_sends_opening_job_once_without_issuing_magic_link(opening_case, monkeypatch):
from django.core.management import call_command
from apps.integrations.sms.base import SMSResult
from apps.accounts.models import TicketMagicLink
c = opening_case
text = render_opening_sms(c["dispatch"])
open_dispatch_responses(c["dispatch"], c["staff"], text)
job = NotificationJob.objects.get(event_type=OPENING_EVENT)
sent = []
class Provider:
def send_message(self, mobile, body):
sent.append((mobile, body))
return SMSResult(True, "opening-test-provider")
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: Provider())
links_before = TicketMagicLink.objects.count()
call_command("process_notifications", once=True, job_id=job.pk)
call_command("process_notifications", once=True, job_id=job.pk)
job.refresh_from_db()
assert job.status == "SENT" and sent == [(c["customers"][0].mobile, text)]
assert TicketMagicLink.objects.count() == links_before
@pytest.mark.django_db(transaction=True)
def test_seed_round_trip_preserves_opening_with_user_primary_key_gaps(opening_case, settings, tmp_path, monkeypatch):
from django.core.management import call_command
from django.db import connection
if connection.vendor != "sqlite":
pytest.skip("Seed export is deliberately restricted to SQLite sources")
c = opening_case
settings.MEDIA_ROOT = tmp_path / "source-media"
User.objects.create_user(username="discarded-seed-contact").delete()
customer = make_contact("stable-seed-respondent", "09122000888")
customer.customer_profile.groups.add(c["group"])
dispatch, tickets = create_group_dispatch(user=c["staff"], unit=c["unit"], contact_group=c["group"],
category=c["category"], subject="بازگشایی با انتقال داده", body="متن", notify_opening_sms=False)
ticket = next(ticket for ticket in tickets if ticket.customer_id == customer.pk)
reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ منتقل‌شونده")
control, _ = open_dispatch_responses(dispatch, c["staff"])
snapshot = control.respondents_snapshot
opened_at = control.opened_at
assert snapshot == [str(customer.public_id)]
bundle = tmp_path / "bundle"
call_command("export_production_seed", output=str(bundle))
call_command("flush", interactive=False, verbosity=0)
settings.MEDIA_ROOT = tmp_path / "destination-media"
monkeypatch.setattr(connection, "vendor", "postgresql")
call_command("import_production_seed", bundle_dir=str(bundle))
restored = DispatchResponseControl.objects.get(dispatch_id=dispatch.pk)
# Django's existing JSON fixture format retains millisecond precision.
assert 0 <= (opened_at - restored.opened_at).total_seconds() < 0.001
assert restored.respondents_snapshot == snapshot
assert restored.opened_by.username == c["staff"].username
assert str(User.objects.get(username=customer.username).public_id) in restored.respondents_snapshot
def test_managed_reply_bale_never_falls_back_to_a_broad_unit_room(opening_case, monkeypatch):
c = opening_case
sent = []
class Provider:
def send_message(self, chat_id, text):
sent.append((chat_id, text))
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_bale_provider", lambda: Provider())
job = NotificationJob.objects.get(event_type="CUSTOMER_TICKET_REPLY", ticket=c["tickets"][0])
assert _send_bale(job) is None
assert not sent
def test_acknowledging_question_does_not_mark_a_sealed_response_ticket_read(client, opening_case):
from apps.tickets.models import TicketReadState
c = opening_case
ticket = c["tickets"][0]
question = ticket.ticket_messages.get(author=c["staff"])
client.force_login(c["admin"])
url = reverse("staff_tickets:read_shares", args=[ticket.public_id])
client.post(url, {"message_ids": [str(question.public_id)]})
assert MessageReadReceipt.objects.filter(message=question, user=c["admin"]).exists()
assert not TicketReadState.objects.filter(ticket=ticket, user=c["admin"]).exists()
open_dispatch_responses(c["dispatch"], c["admin"], render_opening_sms(c["dispatch"]))
assert not MessageReadReceipt.objects.filter(message=c["message"], user=c["admin"]).exists()
client.post(url, {"message_ids": [str(c["message"].public_id)]})
assert TicketReadState.objects.filter(ticket=ticket, user=c["admin"]).exists()
def test_existing_share_audit_cannot_disclose_sealed_body_to_creator_or_admin(client, opening_case):
c = opening_case
MessageShare.objects.create(message=c["message"], user=c["other"], granted_by=c["staff"])
for actor in ("staff", "admin"):
client.force_login(c[actor])
response = client.get(reverse("staff_tickets:detail", args=[c["tickets"][0].public_id]))
assert response.status_code == 200 and c["message"].body not in response.content.decode()
assert not response.context["message_shares"].filter(message=c["message"]).exists()
open_dispatch_responses(c["dispatch"], c["staff"], render_opening_sms(c["dispatch"]))
response = client.get(reverse("staff_tickets:detail", args=[c["tickets"][0].public_id]))
assert c["message"].body in response.content.decode()
+10 -1
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone as dt_timezone
import pytest
from apps.core.templatetags.jalali import jalali_date, jalali_datetime, persian_time
from apps.core.templatetags.jalali import jalali_date, jalali_datetime, jalali_datetime_precise, persian_time
def test_jalali_datetime_uses_iran_time_persian_month_and_digits():
@@ -14,3 +15,11 @@ def test_jalali_filters_fail_safely_for_empty_values():
assert jalali_datetime(None) == ""
assert jalali_date(None) == ""
assert persian_time(None) == ""
@pytest.mark.parametrize("value,expected", [
(datetime(2026, 9, 5, 22, 31, 17, tzinfo=dt_timezone.utc), "۱۵ شهریور ۱۴۰۵، ساعت ۰۲:۰۱:۱۷"),
(None, ""),
])
def test_opening_timestamp_includes_seconds_in_iran_time(value, expected):
assert jalali_datetime_precise(value) == expected
+114
View File
@@ -0,0 +1,114 @@
from datetime import datetime, time, timedelta, timezone as datetime_timezone
import pytest
from django.urls import reverse
from django.utils import timezone
from apps.payments import policies, selectors, services
from apps.payments.forms import ReceiptForm
from apps.payments.models import (
PaymentAccessConfiguration, PaymentApprovalPolicy, PaymentNotice, PaymentReceipt,
PaymentReceiptRevision, ReceiptApprovalCycle,
)
from tests.test_payments import case, data, grant, submit, upload
pytestmark = pytest.mark.django_db
def test_daily_summary_uses_current_amounts_final_outcomes_and_all_pending(case, django_assert_num_queries):
independent_policy = PaymentApprovalPolicy.objects.create(name='بررسی فیش مستقل')
independent_policy.reviewers.add(case['first'], case['second'])
approved = submit(case)
services.decide_receipt(case['first'], approved.public_id, 1, 'APPROVE')
services.decide_receipt(case['second'], approved.public_id, 1, 'APPROVE')
rejected = submit(case, linked=False)
services.decide_receipt(case['first'], rejected.public_id, 1, 'REJECT', 'ناخوانا')
partial = submit(case)
services.decide_receipt(case['first'], partial.public_id, 1, 'APPROVE')
pending = submit(case, linked=False)
revised = submit(case)
services.decide_receipt(case['first'], revised.public_id, 1, 'REJECT', 'اصلاح مبلغ')
form = ReceiptForm(data(case, paid_amount='9000'), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
services.revise_receipt(case['customer'], revised.public_id, 1, form.cleaned_data)
PaymentReceiptRevision.objects.filter(version=1).update(amount=100)
yesterday = timezone.now() - timedelta(days=1)
PaymentReceipt.objects.filter(pk__in=[approved.pk, pending.pk]).update(created_at=yesterday)
# The written bank date is unrelated to when evidence arrived or was reviewed.
PaymentReceiptRevision.objects.update(paid_date=yesterday.date())
with django_assert_num_queries(2):
summary = selectors.daily_payment_summary(
PaymentReceipt.objects.all(), PaymentNotice.objects.filter(deleted_at__isnull=True))
assert summary == {
'date': timezone.localdate(), 'submitted_count': 3, 'submitted_amount': 9200,
'approved_count': 1, 'approved_amount': 100, 'rejected_count': 1, 'rejected_amount': 100,
'pending_count': 3, 'pending_amount': 9200, 'notice_count': 1,
}
@pytest.mark.parametrize('offset,included', [
(-1, False), (0, True), (24 * 60 * 60 - 1, True), (24 * 60 * 60, False),
])
def test_daily_summary_tehran_midnight_boundaries(case, monkeypatch, offset, included):
receipt = submit(case)
services.decide_receipt(case['senior'], receipt.public_id, 1, 'SENIOR_APPROVE', 'بررسی نهایی')
today = timezone.localdate()
start = timezone.make_aware(datetime.combine(today, time.min), timezone.get_default_timezone())
stamp = start + timedelta(seconds=offset)
PaymentReceipt.objects.filter(pk=receipt.pk).update(created_at=stamp)
PaymentNotice.objects.filter(pk=case['notice'].pk).update(created_at=stamp)
ReceiptApprovalCycle.objects.filter(revision__receipt=receipt).update(finalized_at=stamp)
monkeypatch.setattr(timezone, 'now', lambda: start + timedelta(hours=1))
# An active display timezone must not move the Tehran business-day boundary.
with timezone.override(datetime_timezone.utc):
summary = selectors.daily_payment_summary(PaymentReceipt.objects.all(), PaymentNotice.objects.all())
assert summary['date'] == today
assert summary['submitted_count'] == summary['approved_count'] == summary['notice_count'] == int(included)
assert summary['submitted_amount'] == summary['approved_amount']
assert bool(summary['submitted_amount']) == included
def test_daily_summary_excludes_old_finalizations_and_deleted_notices(case):
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'ناخوانا')
ReceiptApprovalCycle.objects.filter(revision__receipt=receipt).update(
finalized_at=timezone.now() - timedelta(days=1))
PaymentNotice.objects.filter(pk=case['notice'].pk).update(deleted_at=timezone.now())
summary = selectors.daily_payment_summary(
policies.visible(PaymentReceipt.objects.all(), case['creator']),
policies.visible(PaymentNotice.objects.all(), case['creator']))
assert summary['submitted_count'] == 1
assert summary['rejected_count'] == summary['rejected_amount'] == summary['notice_count'] == 0
def test_daily_summary_permission_scope_and_zero_state(case, client):
submit(case)
PaymentAccessConfiguration.objects.update_or_create(pk=1, defaults={'open_view': False})
client.force_login(case['outsider'])
response = client.get(reverse('staff_payments:desk'))
assert response.status_code == 200
summary = response.context['daily_summary']
assert all(value == 0 for key, value in summary.items() if key != 'date')
grant(case['outsider'], 'view_paymentreceipt')
response = client.get(reverse('staff_payments:desk'))
assert response.context['daily_summary']['submitted_count'] == 1
assert response.context['daily_summary']['notice_count'] == 0
grant(case['outsider'], 'view_paymentnotice')
assert client.get(reverse('staff_payments:desk')).context['daily_summary']['notice_count'] == 1
def test_daily_summary_stays_independent_of_tabs_search_and_pagination(case, client):
submit(case)
client.force_login(case['creator'])
endpoint = reverse('staff_payments:desk')
baseline = client.get(endpoint).context['daily_summary']
for parameters in [
{'tab': 'notices', 'notice_state': 'cancelled'},
{'tab': 'receipts', 'mine': '1', 'q': 'no-such-customer', 'page': '99'},
{'tab': 'receipts', 'status': 'APPROVED', 'kind': 'independent', 'from': '2000-01-01', 'to': '2000-01-02'},
]:
response = client.get(endpoint, parameters)
assert response.status_code == 200
assert response.context['daily_summary'] == baseline
assert response.context['page_obj'].paginator.count == 0
assert 'مبنای آمار' in response.content.decode()
+57 -11
View File
@@ -42,14 +42,12 @@ def run(job):
return job
def test_initially_disabled_and_staff_uploads_do_not_notify(case, enabled, monkeypatch):
def test_disabled_channels_do_not_notify_customer_or_staff_uploads(case, enabled, monkeypatch):
network = Mock(side_effect=AssertionError('Network must not run in registration'))
monkeypatch.setattr(requests, 'post', network)
enabled.bale_enabled = enabled.sms_enabled = False
enabled.save()
submit(case)
enabled.bale_enabled = enabled.sms_enabled = True
enabled.save()
form = ReceiptForm(data(case, linked=False), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
@@ -57,6 +55,38 @@ def test_initially_disabled_and_staff_uploads_do_not_notify(case, enabled, monke
network.assert_not_called()
@pytest.mark.parametrize('picker,linked', [(False, True), (False, False), (True, True), (True, False)])
def test_staff_entry_points_enqueue_once_with_customer_identity(case, enabled, client, monkeypatch, picker, linked):
network = Mock(side_effect=AssertionError('Network must not run in registration'))
monkeypatch.setattr(requests, 'post', network)
client.force_login(case['creator'])
endpoint = reverse('staff_payments:receipt_new', args=[case['customer'].public_id])
if picker:
response = client.post(reverse('staff_payments:receipt_customer'), {'customer': case['customer'].pk})
assert response.status_code == 302 and response.url == endpoint
assert client.get(endpoint).status_code == 200
values = data(case, linked=linked)
invalid = client.post(endpoint, {**values, 'paid_amount': '0', 'receipt_image': upload()},
HTTP_ACCEPT='application/json')
assert invalid.status_code == 422
assert not PaymentReceiptAlert.objects.exists()
for _ in range(2):
response = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
assert response.status_code == 200, response.content
receipt = PaymentReceipt.objects.get(customer=case['customer'])
assert receipt.submitted_by_id == case['creator'].pk
assert receipt.notice_id == (case['notice'].pk if linked else None)
alerts = list(receipt.revisions.get().alerts.select_related('job'))
assert len(alerts) == 3
assert {a.job.channel for a in alerts} == {'SMS', 'BALE'}
assert {a.recipient_mobile for a in alerts} == {'', '+989121110001', '+989121110002'}
assert all(a.job.customer_id == case['customer'].pk for a in alerts)
assert all(a.context['customer_mobile'] == case['customer'].local_mobile for a in alerts)
assert all('واریزکننده آزمایشی' in a.context['customer_name'] for a in alerts)
assert all(a.context['receipt_link'].endswith(reverse('staff_payments:receipt_detail', args=[receipt.public_id])) for a in alerts)
network.assert_not_called()
@pytest.mark.parametrize('anonymous,linked', [(False, True), (False, False), (True, True), (True, False)])
def test_all_customer_entry_points_enqueue_once_without_network(case, enabled, client, monkeypatch, anonymous, linked):
network = Mock(side_effect=AssertionError('Network must not run in registration'))
@@ -85,27 +115,37 @@ def test_all_customer_entry_points_enqueue_once_without_network(case, enabled, c
network.assert_not_called()
def test_customer_corrections_have_separate_idempotent_alerts_and_can_be_disabled(case, enabled):
@pytest.mark.parametrize('staff', [False, True])
def test_customer_corrections_have_separate_idempotent_alerts_and_can_be_disabled(case, enabled, staff):
receipt = submit(case)
actor = case['creator'] if staff else case['customer']
if staff:
grant(actor, 'submit_customer_receipt')
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خوانا نیست')
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
services.revise_receipt(case['customer'], receipt.public_id, 1, form.cleaned_data)
services.revise_receipt(actor, receipt.public_id, 1, form.cleaned_data)
assert PaymentReceiptAlert.objects.count() == 6
receipt_alerts.safe_queue_receipt_alerts(receipt.revisions.get(version=2), case['customer'])
receipt_alerts.safe_queue_receipt_alerts(receipt.revisions.get(version=2), actor)
assert PaymentReceiptAlert.objects.count() == 6
enabled.notify_revisions = False
enabled.save()
services.decide_receipt(case['first'], receipt.public_id, 2, 'REJECT', 'اصلاح لازم است')
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid()
services.revise_receipt(case['customer'], receipt.public_id, 2, form.cleaned_data)
services.revise_receipt(actor, receipt.public_id, 2, form.cleaned_data)
assert PaymentReceiptAlert.objects.count() == 6
def test_outer_rollback_removes_receipt_and_alerts(case, enabled):
@pytest.mark.parametrize('staff', [False, True])
def test_outer_rollback_removes_receipt_and_alerts(case, enabled, staff):
with pytest.raises(RuntimeError), transaction.atomic():
submit(case)
if staff:
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
else:
submit(case)
raise RuntimeError('rollback')
assert not PaymentReceipt.objects.exists() and not PaymentReceiptAlert.objects.exists()
assert not NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT).exists()
@@ -131,9 +171,15 @@ def test_configuration_failure_isolated_from_registration(case, enabled, monkeyp
assert receipt.revisions.count() == 1
def test_worker_sends_private_photo_with_database_token_and_sms_to_managers(case, enabled, monkeypatch, settings):
@pytest.mark.parametrize('staff', [False, True])
def test_worker_sends_private_photo_with_database_token_and_sms_to_managers(case, enabled, monkeypatch, settings, staff):
settings.BALE_BOT_TOKEN = 'existing-ticket-token'
receipt = submit(case)
if staff:
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
receipt = services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
else:
receipt = submit(case)
posted, sent = [], []
def post(url, **kwargs):
posted.append((url, kwargs['data'], kwargs['files']['photo'][1].read(), kwargs['timeout']))