fix sms and ticket user frindly

This commit is contained in:
javad committed 2026-09-14 15:48:13 +03:30
1 parent 1ee83374fd
commit b8b3aba855
30 files changed
+2220 -119

No files matched your search

+27 -7
View File
@@ -37,7 +37,8 @@ class TicketConversationPage(BasePage):
class PublicTicketCreatePage(BasePage):
def open(self):
self.goto("/tickets/new/")
expect(self.unit).to_be_visible()
expect(self.page.locator('[data-wizard-step="1"]')).to_be_visible()
expect(self.page.locator('[data-ticket-subject]')).to_be_visible()
@property
def unit(self):
@@ -66,7 +67,10 @@ class PublicTicketCreatePage(BasePage):
and f"unit={unit_id}" in response.url
and response.status == 200
) as response_info:
self.unit.select_option(unit_id)
self.unit.evaluate(
"(node, value) => { node.value = value; node.dispatchEvent(new Event('change', {bubbles: true})); }",
unit_id,
)
payload = response_info.value.json()
expect(self.page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", unit_id)
expected_ids = {str(item["id"]) for item in payload["experts"]}
@@ -84,17 +88,24 @@ class PublicTicketCreatePage(BasePage):
card = self.expert_picker.locator("label.expert-card").filter(has_text=display_name)
expect(card).to_have_count(1)
radio = card.locator('input[name="selected_expert"]')
radio.check()
radio.evaluate(
"node => { node.checked = true; node.dispatchEvent(new Event('change', {bubbles: true})); }"
)
expect(radio).to_be_checked()
return radio.get_attribute("value")
def select_general_queue(self):
radio = self.queue.locator('input[name="selected_expert"]')
radio.check()
radio.evaluate(
"node => { node.checked = true; node.dispatchEvent(new Event('change', {bubbles: true})); }"
)
expect(radio).to_be_checked()
def select_category(self, category_id):
self.category.select_option(str(category_id))
self.category.evaluate(
"(node, value) => { node.value = value; node.dispatchEvent(new Event('change', {bubbles: true})); }",
str(category_id),
)
expect(self.category).to_have_value(str(category_id))
def upload_gallery(self, files, *, total_count=None):
@@ -108,13 +119,22 @@ class PublicTicketCreatePage(BasePage):
def remove_attachment(self, filename):
chip = self.file_preview.locator(".file-chip").filter(has_text=filename)
expect(chip).to_have_count(1)
chip.get_by_role("button", name="حذف فایل").click()
chip.locator("button").evaluate("node => node.click()")
expect(self.file_preview.locator(".file-chip").filter(has_text=filename)).to_have_count(0)
def submit(self, *, subject, body):
self.page.locator('input[name="subject"]').fill(subject)
self.page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
expect(self.page.locator('[data-wizard-step="2"]')).to_be_visible()
self.page.locator('[data-wizard-step="2"] [data-wizard-next]').click()
expect(self.page.locator('[data-wizard-step="3"]')).to_be_visible()
self.page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
expect(self.page.locator('[data-wizard-step="4"]')).to_be_visible()
self.page.locator('textarea[name="body"]').fill(body)
self.page.locator('[data-wizard-step="4"] [data-wizard-next]').click()
expect(self.page.locator('[data-wizard-step="5"]')).to_be_visible()
expect(self.page.locator('[data-review-subject]')).to_have_text(subject)
with self.page.expect_navigation(wait_until="domcontentloaded"):
self.page.get_by_role("button", name="ثبت و ارسال درخواست").click()
self.page.get_by_role("button", name="ثبت و ارسال نهایی").click()
expect(self.page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$"))
expect(self.page.locator("h1")).to_have_text(subject)
@@ -1,4 +1,5 @@
import io
import re
import pytest
from django.core.management import call_command
@@ -133,6 +134,8 @@ def test_e2e_subject_stays_empty_for_manual_and_search_routing_until_user_types(
with browser_page() as page:
form = open_form(page, live_server, customer)
subject_input = page.locator("[data-ticket-subject]")
page.locator(".ticket-manual-routing summary").click()
expect(form.unit).to_be_visible()
unit_box = form.unit.bounding_box()
category_box = form.category.bounding_box()
assert unit_box and category_box
@@ -159,6 +162,115 @@ def test_e2e_subject_stays_empty_for_manual_and_search_routing_until_user_types(
)
def test_e2e_customer_wizard_validation_preview_edit_and_floating_submit(
live_server, browser_page, public_workflow
):
customer, units, categories = public_workflow
subject = "بازبینی مرحله‌ای و ویرایش E2E"
with browser_page() as page:
page.set_viewport_size({"width": 390, "height": 640})
form = open_form(page, live_server, customer)
expect(page.locator('[data-wizard-step="1"]')).to_be_visible()
expect(page.locator('[data-wizard-step="2"]')).to_be_hidden()
expect(page.locator('[data-ticket-search-results]')).to_be_hidden()
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
error = page.locator('[data-wizard-error="1"]')
expect(error).to_be_visible()
expect(error).to_be_in_viewport()
expect(page.locator('[data-ticket-subject]')).to_have_attribute("aria-invalid", "true")
expect(form.category).to_be_visible()
assert page.evaluate("document.activeElement === document.querySelector('[data-wizard-error=\"1\"]')")
form.select_search_result("عمومی فروش", "عمومی فروش")
expect(page.locator('[data-ticket-subject]')).to_have_value("")
page.locator('[data-ticket-subject]').fill(subject)
expect(page.locator('[data-ticket-subject]')).to_have_value(subject)
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
page.locator('[data-wizard-step="2"] [data-wizard-next]').click()
page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
page.locator('textarea[name="body"]').fill("شرح خوانا برای پیش‌نمایش مرحله پنجم")
page.locator('[data-wizard-step="4"] [data-wizard-next]').click()
expect(page.locator('[data-wizard-step="5"]')).to_be_visible()
expect(page.locator('[data-review-subject]')).to_have_text(subject)
expect(page.locator('[data-review-body]')).to_contain_text("شرح خوانا")
final_submit = page.get_by_role("button", name="ثبت و ارسال نهایی")
expect(final_submit).to_be_visible()
submit_box = final_submit.bounding_box()
assert submit_box and submit_box["y"] + submit_box["height"] <= page.viewport_size["height"]
assert page.locator('[data-review-subject]').evaluate("node => parseFloat(getComputedStyle(node).fontSize)") >= 16
assert page.locator('[data-review-body]').evaluate("node => parseFloat(getComputedStyle(node).fontSize)") >= 14
page.locator('[data-wizard-edit="1"]').click()
expect(page.locator('[data-ticket-subject]')).to_have_value(subject)
expect(page.locator('[data-ticket-unit]')).to_have_value(str(units["sales"].pk))
expect(page.locator('[data-ticket-category]')).to_have_value(str(categories["sales_general"].pk))
def test_e2e_customer_wizard_auto_stops_voice_and_submits_audio_only_ticket(
live_server, browser_page, public_workflow
):
customer, units, categories = public_workflow
subject = "ثبت تیکت فقط با پیام صوتی E2E"
with browser_page() as page:
page.add_init_script(
"""
window.__microphoneTrackStops = 0;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {getUserMedia: async () => ({getTracks: () => [{stop: () => {window.__microphoneTrackStops += 1;}}]})},
});
class FakeMediaRecorder {
constructor(stream, options = {}) {this.stream = stream; this.mimeType = options.mimeType || 'audio/wav'; this.state = 'inactive';}
start() {this.state = 'recording';}
pause() {this.state = 'paused';}
resume() {this.state = 'recording';}
stop() {
this.state = 'inactive';
const wav = new Uint8Array([
82,73,70,70,40,0,0,0,87,65,86,69,102,109,116,32,
16,0,0,0,1,0,1,0,68,172,0,0,136,88,1,0,
2,0,16,0,100,97,116,97,4,0,0,0,0,0,0,0,
]);
this.ondataavailable?.({data: new Blob([wav], {type: this.mimeType})});
setTimeout(() => this.onstop?.(), 0);
}
static isTypeSupported(type) {return type === 'audio/wav';}
}
Object.defineProperty(window, 'MediaRecorder', {configurable: true, value: FakeMediaRecorder});
"""
)
form = open_form(page, live_server, customer)
form.select_unit(units["sales"].pk)
form.select_category(categories["sales_general"].pk)
form.select_general_queue()
page.locator('[data-ticket-subject]').fill(subject)
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
page.locator('[data-wizard-step="2"] [data-wizard-next]').click()
page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
expect(page.locator('[data-wizard-step="4"]')).to_be_visible()
page.get_by_role("button", name="شروع ضبط صدا").click()
expect(page.locator('[data-voice-live]')).to_be_visible()
page.get_by_role("button", name="مشاهده پیش‌نمایش").click()
expect(page.locator('[data-wizard-step="5"]')).to_be_visible()
expect(page.locator('[data-review-audio]')).to_be_visible()
assert page.evaluate("window.__microphoneTrackStops") == 1
with page.expect_navigation(wait_until="domcontentloaded"):
page.get_by_role("button", name="ثبت و ارسال نهایی").click()
expect(page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$"))
ticket = Ticket.objects.get(subject=subject)
assert ticket.owning_unit == units["sales"]
assert ticket.category == categories["sales_general"]
attachments = list(ticket.ticket_messages.get().attachments.all())
assert len(attachments) == 1
assert attachments[0].mime_type.startswith("audio/")
assert attachments[0].original_name.startswith("voice-")
def test_e2e_experts_follow_configured_numeric_order(live_server, browser_page, public_workflow):
customer, units, _ = public_workflow
configured = {
+66 -3
View File
@@ -3,15 +3,16 @@ import base64
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.core.models import PageTitle, SiteBranding
from apps.notifications.models import SMSTemplate
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_ticket
from apps.tickets.services import create_ticket, dispatch_group_tickets
from tests.e2e.pages.auth import AuthenticationPage
@@ -79,6 +80,20 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
expect(page.locator(".staff-developer-credit")).to_contain_text("امضای مستقل sidebar E2E")
expect(page.get_by_role("searchbox", name="جستجو")).to_be_visible()
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()
tiny_png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
page.locator("#dispatch-gallery-input").set_input_files([
{"name":"dispatch-first.png","mimeType":"image/png","buffer":tiny_png},
{"name":"dispatch-second.png","mimeType":"image/png","buffer":tiny_png},
])
expect(page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip")).to_have_count(2)
expect(page.locator(".dispatch-attachment-picker [data-file-preview] img")).to_have_count(2)
page.locator("#dispatch-file-input").set_input_files({"name":"dispatch-document.pdf","mimeType":"application/pdf","buffer":b"%PDF-1.4\n%%EOF"})
expect(page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip")).to_have_count(3)
page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip button").first.click()
expect(page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip")).to_have_count(2)
assert page.locator('.dispatch-attachment-picker input[name="files"]').evaluate("input => input.files.length") == 2
page.goto(f"{live_server.url}{reverse('staff_tickets:sms_templates')}", wait_until="domcontentloaded")
expect(page.get_by_text("الگوی عمومی سیستم").first).to_be_visible()
@@ -105,7 +120,6 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
expect(page.locator(f'input[name="groups"][value="{secondary_group.pk}"]')).to_be_checked()
page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded")
tiny_png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
page.locator("#staff-ticket-gallery-input").set_input_files([
{"name": "first.png", "mimeType": "image/png", "buffer": tiny_png},
{"name": "second.png", "mimeType": "image/png", "buffer": tiny_png},
@@ -115,3 +129,52 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
page.locator("[data-file-preview] .file-chip button").first.click()
expect(page.locator("[data-file-preview] .file-chip")).to_have_count(1)
assert page.locator('input[name="files"]').evaluate("input => input.files.length") == 1
def test_dispatch_report_presents_delivery_and_engagement_professionally(live_server, browser_page):
from apps.core.templatetags.jalali import jalali_datetime
unit=OrganizationalUnit.objects.create(name="فروش گزارش E2E",slug="dispatch-report-e2e")
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets",codename__in=("view_ticket","dispatch_group_tickets")))
staff=User.objects.create_user(username="dispatch-report-agent",password=PASSWORD,account_type=User.AccountType.STAFF)
StaffProfile.objects.create(user=staff,display_name="کارشناس گزارش",primary_unit=unit)
customer=User.objects.create_user(username="dispatch-report-customer",mobile="09120009131",account_type=User.AccountType.CUSTOMER)
profile=CustomerProfile.objects.create(user=customer,first_name="مخاطب",last_name="آزمایشی")
group=ContactGroup.objects.create(unit=unit,name="گروه گزارش حرفه‌ای")
profile.groups.add(group)
category=TicketCategory.objects.create(unit=unit,title="استعلام گزارش",slug="dispatch-report-category-e2e")
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="استعلام حرفه‌ای",body="این متن اولیه کارشناس باید بالای گزارش دیده شود.")
ticket=tickets[0]
job=NotificationJob.objects.get(ticket=ticket,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
NotificationJob.objects.filter(pk=job.pk).update(
status=NotificationJob.Status.SENT,attempts=1,sent_at=timezone.now(),
provider_message_id="1111182358",provider_status_code="1111182358",
delivery_status=NotificationJob.DeliveryStatus.DELIVERED,delivery_status_code="5",
delivery_description="0098SMS [کد 5]: پیامک به گوشی گیرنده رسیده است.",
delivery_check_attempts=2,delivery_checked_at=timezone.now(),delivery_next_check_at=None,
)
links=[TicketMagicLink.issue_with_record(ticket,created_by=staff)[1] for _ in range(4)]
links[0].view_count=2; links[0].used_at=timezone.now(); links[0].save(update_fields=["view_count","used_at"])
with browser_page() as page:
AuthenticationPage(page,live_server.url).login_staff(staff,PASSWORD)
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_detail',args=[dispatch.public_id])}",wait_until="domcontentloaded")
expect(page.get_by_role("heading",name="استعلام حرفه‌ای",level=1)).to_be_visible()
expect(page.get_by_text("این متن اولیه کارشناس باید بالای گزارش دیده شود.")).to_be_visible()
expect(page.locator(".sms-state-card")).to_contain_text("تحویل به گوشی تأیید شد")
expect(page.locator(".engagement-state")).to_contain_text("2 بازدید")
expect(page.locator('[role="progressbar"]')).to_have_attribute("aria-valuenow","100")
expect(page.locator(".delivery-progress-copy")).to_contain_text("100٪")
assert float(page.locator(".sms-state-card strong").evaluate("node => parseFloat(getComputedStyle(node).fontSize)")) >= 12
assert float(page.locator(".delivery-progress-track").evaluate("node => parseFloat(getComputedStyle(node).height)")) <= 8
created_text=page.locator(".dispatch-meta-list time").inner_text()
assert created_text == jalali_datetime(dispatch.created_at)
assert "0 + 0" not in page.locator("body").inner_text()
page.get_by_text("جزئیات فنی و رهگیری").click()
expect(page.get_by_text("1111182358")).to_be_visible()
expect(page.get_by_text("موفق در اولین تلاش")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
page.set_viewport_size({"width":390,"height":844})
expect(page.locator(".dispatch-detail-table tbody tr").first).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
+143 -10
View File
@@ -421,6 +421,105 @@ def test_due_unread_staff_reply_is_sent_once(category,customer,monkeypatch):
call_command("process_notifications",once=True,job_id=job.pk)
assert len(sent)==1
@pytest.mark.django_db
def test_every_failed_sms_gets_exactly_three_retries_three_minutes_apart(category,customer,monkeypatch,settings):
from django.core.management import call_command
from apps.integrations.sms.base import SMSResult
from apps.notifications.management.commands import process_notifications
settings.SMS_MAX_RETRIES=3
settings.SMS_RETRY_DELAY_SECONDS=180
ticket=create_ticket(customer=customer,category=category,subject="آزمون retry پیامک",body="متن")
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
calls=[]
class Provider:
def send_message(self,mobile,message):
calls.append((mobile,message))
return SMSResult(False,error="0098SMS [کد -19]: شارژ پنل برای ارسال کافی نیست.",provider_code="-19")
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider())
for attempt in range(1,5):
before=timezone.now()
call_command("process_notifications",once=True,job_id=job.pk)
job.refresh_from_db()
assert job.attempts==attempt
assert job.provider_status_code=="-19"
assert "کد -19" in job.last_error
if attempt<4:
assert job.status==NotificationJob.Status.RETRYING
assert timedelta(seconds=179) <= job.next_attempt_at-before <= timedelta(seconds=181)
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now()-timedelta(seconds=1))
else:
assert job.status==NotificationJob.Status.FAILED
call_command("process_notifications",once=True,job_id=job.pk)
assert len(calls)==4
@pytest.mark.django_db
def test_worker_persists_0098_delivery_result(category,customer,monkeypatch,settings):
from django.core.management import call_command
from apps.integrations.sms.base import SMSDeliveryResult
from apps.notifications.management.commands import process_notifications
ticket=create_ticket(customer=customer,category=category,subject="استعلام دلیوری",body="متن")
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
NotificationJob.objects.filter(pk=job.pk).update(
status=NotificationJob.Status.SENT,
provider_message_id="123456789",
delivery_status=NotificationJob.DeliveryStatus.PENDING,
delivery_next_check_at=timezone.now()-timedelta(seconds=1),
)
class Provider:
def get_delivery_status(self,provider_id):
assert provider_id=="123456789"
return SMSDeliveryResult("DELIVERED","5","0098SMS [کد 5]: پیامک به گوشی گیرنده رسیده است.")
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider())
call_command("process_notifications",once=True,job_id=job.pk)
job.refresh_from_db()
assert job.delivery_status==NotificationJob.DeliveryStatus.DELIVERED
assert job.delivery_status_code=="5"
assert job.delivery_next_check_at is None
@pytest.mark.django_db
def test_worker_rechecks_provisional_delivery_until_definitive_result(category,customer,monkeypatch,settings):
from django.core.management import call_command
from apps.integrations.sms.base import SMSDeliveryResult
from apps.notifications.management.commands import process_notifications
settings.SMS_DELIVERY_CHECK_DELAY_SECONDS=180
settings.SMS_DELIVERY_MAX_CHECKS=15
ticket=create_ticket(customer=customer,category=category,subject="پیگیری طولانی دلیوری",body="متن")
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
NotificationJob.objects.filter(pk=job.pk).update(
status=NotificationJob.Status.SENT, sent_at=timezone.now(), provider_message_id="123456789",
delivery_status=NotificationJob.DeliveryStatus.PENDING,
delivery_next_check_at=timezone.now()-timedelta(seconds=1),
)
results=[
SMSDeliveryResult("UNKNOWN","8","وضعیت هنوز مشخص نشده است.",is_final=False),
SMSDeliveryResult("UNDELIVERED","6","تحویل هنوز تأیید نشده است.",is_final=False),
SMSDeliveryResult("DELIVERED","5","پیامک به گوشی رسیده است."),
]
class Provider:
def get_delivery_status(self,provider_id): return results.pop(0)
provider=Provider()
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:provider)
for expected_status,expected_attempts in ((NotificationJob.DeliveryStatus.UNKNOWN,1),(NotificationJob.DeliveryStatus.UNDELIVERED,2)):
before=timezone.now()
call_command("process_notifications",once=True,job_id=job.pk)
job.refresh_from_db()
assert job.delivery_status==expected_status
assert job.delivery_check_attempts==expected_attempts
assert timedelta(seconds=179) <= job.delivery_next_check_at-before <= timedelta(seconds=181)
NotificationJob.objects.filter(pk=job.pk).update(delivery_next_check_at=timezone.now()-timedelta(seconds=1))
call_command("process_notifications",once=True,job_id=job.pk)
job.refresh_from_db()
assert job.delivery_status==NotificationJob.DeliveryStatus.DELIVERED
assert job.delivery_check_attempts==3 and job.delivery_next_check_at is None
@pytest.mark.django_db
def test_customer_ticket_created_sms_has_seven_day_magic_link(category,customer,settings,monkeypatch):
from datetime import timedelta
@@ -512,16 +611,50 @@ def test_behin_provider_maps_send_array_contract(settings):
assert calls["CheckingMessageID"]=={"long":[0]}
assert calls["SpecialNumber"]=="3000" and calls["IsFlashMessage"] is False
def test_0098_provider_maps_http_contract(settings,monkeypatch):
def test_0098_provider_maps_soap_contract(settings):
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
calls={}
class Response:
text="42"
def raise_for_status(self): return None
def fake_get(url,**kwargs): calls.update(url=url,**kwargs); return Response()
monkeypatch.setattr("apps.integrations.sms.sms0098.requests.get",fake_get)
result=ZeroZeroNinetyEightSMSProvider().send_message("09202202129","پیام آزمایشی")
assert result.success and result.provider_id=="42"
assert calls["params"]["TO"]==9202202129
assert calls["params"]["TEXT"]=="پیام آزمایشی"
class Service:
def SendSMSWithID(self,**parameters): calls.update(parameters); return "123456789"
class Client: service=Service()
provider=ZeroZeroNinetyEightSMSProvider(); provider._client=Client()
result=provider.send_message("09202202129","پیام آزمایشی")
assert result.success and result.provider_id=="123456789"
assert calls["mobileno"]=="09202202129"
assert calls["text"]=="پیام آزمایشی"
assert calls["pnlno"]=="3000"
assert set(calls)=={"username","password","text","mobileno","pnlno"}
@pytest.mark.parametrize("code,expected", [
("-3", "تطابق ندارند"), ("10", "اشتباه است"), ("11", "کاراکتر غیرمجاز"),
("-17", "خالی است"), ("-18", "خطای شارژ"), ("-19", "شارژ پنل"),
("-22", "شماره موبایل"), ("66", "تطابق ندارند"), ("1111", "کاراکتر غیرمجاز"),
("Hang", "مسدود است"), ("No Doc", "مرحله دوم"), ("-23", "مجوز ارسال"),
("-24", "شماره پنل"), ("-25", "اعتبار شماره پنل"), ("-26", "تنظیمات کد OTP"),
("-27", "فرمت کد OTP"), ("-28", "تأیید نشده"), ("Wrong timestamp", "timestamp"),
("Wrong time", "خارج از بازه"), ("-29", "فرمت کد OTP"), ("-31", "یافت نشد"),
("-32", "مطابقت ندارد"), ("13", "IP غیرمجاز"),
])
def test_0098_provider_maps_every_documented_send_error(settings,code,expected):
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
provider=ZeroZeroNinetyEightSMSProvider()
result=provider._parse_send_result(code)
assert not result.success
assert result.provider_code==code
assert expected in result.error
@pytest.mark.parametrize("code,status,is_final", [("4","BLOCKED",True),("5","DELIVERED",True),("6","UNDELIVERED",False),("7","EXPIRED",True),("8","UNKNOWN",False),("9","ERROR",True),("12","ERROR",True),("13","ERROR",True)])
def test_0098_provider_maps_documented_delivery_states(settings,code,status,is_final):
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
class Service:
def smsdeliveryState(self,**parameters): return code
class Client: service=Service()
provider=ZeroZeroNinetyEightSMSProvider(); provider._client=Client()
result=provider.get_delivery_status("123456789")
assert result.status==status and result.provider_code==code and result.is_final is is_final
assert f"کد {code}" in result.description
+100 -3
View File
@@ -1,7 +1,11 @@
import base64
from datetime import timedelta
import pytest
from django.contrib.auth.models import Permission
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User
from apps.attachments.models import AttachmentPolicy
@@ -157,6 +161,41 @@ def test_group_dispatch_copies_shared_document_into_each_private_ticket():
assert len(set(paths))==2
@pytest.mark.django_db
def test_dispatch_page_supports_professional_multi_attachment_picker_and_submission(client):
unit=OrganizationalUnit.objects.create(name="واحد پیوست گروهی",slug="dispatch-attachment-unit")
unit.permissions.add(Permission.objects.get(codename="dispatch_group_tickets",content_type__app_label="tickets"))
staff=make_staff("dispatch-attachment-staff",unit)
customer=make_contact("dispatch-attachment-customer","09120000313")
group=ContactGroup.objects.create(name="گروه پیوست",unit=unit)
customer.customer_profile.groups.add(group)
category=TicketCategory.objects.create(unit=unit,title="اسناد گروهی",slug="dispatch-attachments-category")
AttachmentPolicy.objects.update_or_create(attachment_type="IMAGE",defaults={"max_file_size":1024*1024,"max_files_per_message":10,"allowed_extensions":[".png"],"allowed_mime_types":["image/png"]})
AttachmentPolicy.objects.update_or_create(attachment_type="PDF",defaults={"max_file_size":1024*1024,"max_files_per_message":10,"allowed_extensions":[".pdf"],"allowed_mime_types":["application/pdf"]})
client.force_login(staff)
url=reverse("staff_tickets:dispatch_create")
page=client.get(url)
content=page.content.decode()
assert page.status_code==200
for marker in ("data-ticket-composer","data-gallery-input","data-document-input","data-file-preview","attachment-uploader.js"):
assert marker in content
assert "افزودن عکس از گالری" in content and "افزودن فایل" in content
response=client.post(url,{
"operation_type":GroupDispatch.OperationType.BULK,"unit":unit.pk,"contact_group":group.pk,
"category":category.pk,"subject":"ارسال چند پیوست گروهی","body":"تصویر و سند را بررسی کنید.",
"files":[
SimpleUploadedFile("preview.png",base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="),content_type="image/png"),
SimpleUploadedFile("document.pdf",b"%PDF-1.4\n%%EOF",content_type="application/pdf"),
],
})
assert response.status_code==302, response.context["form"].errors.as_json() if response.context else response.content.decode()
ticket=Ticket.objects.get(subject="ارسال چند پیوست گروهی")
assert set(ticket.ticket_messages.get().attachments.values_list("original_name",flat=True))=={"preview.png","document.pdf"}
@pytest.mark.django_db
def test_magic_link_records_every_open_with_ip_and_user_agent(client):
unit = OrganizationalUnit.objects.create(name="حسابداری ممیزی", slug="audit-accounting")
@@ -367,29 +406,87 @@ def test_unit_scoped_add_ticket_preserves_contact_ownership_and_stakeholder(clie
@pytest.mark.django_db
def test_dispatch_retry_is_unit_scoped_and_refreshes_persistent_status(client):
from apps.core.templatetags.jalali import jalali_datetime
unit=OrganizationalUnit.objects.create(name="لوجستیک retry",slug="logistics-retry")
other=OrganizationalUnit.objects.create(name="فروش retry",slug="sales-retry")
permission=Permission.objects.get(content_type__app_label="tickets",codename="dispatch_group_tickets")
unit.permissions.add(permission)
unit.permissions.add(permission,Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
staff=make_staff("retry-dispatcher",unit); outsider=make_staff("retry-outsider",other)
contact=make_contact("retry-carrier","09120000302")
group=ContactGroup.objects.create(name="باربری retry",unit=unit); contact.customer_profile.groups.add(group)
category=TicketCategory.objects.create(title="استعلام retry",slug="quote-retry")
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="استعلام هزینه جدید",body="قیمت را ثبت کنید.")
job=NotificationJob.objects.get(ticket=tickets[0],channel="SMS")
job.status=NotificationJob.Status.FAILED; job.attempts=3; job.last_error="provider"; job.save()
job.status=NotificationJob.Status.FAILED; job.attempts=4; job.last_error="0098SMS [کد -19]: شارژ پنل برای ارسال کافی نیست."; job.provider_status_code="-19"; job.save()
for _ in range(4):
TicketMagicLink.issue_with_record(tickets[0],reason="staff_created_ticket",created_by=staff)
# Supervisor alerts are separate from the recipient message and must not
# alter dispatch metrics or become the row's displayed/retryable SMS.
NotificationJob.objects.create(
channel=NotificationJob.Channel.SMS,event_type="UNIT_SUPERVISOR_NEW_TICKET",
ticket=tickets[0],customer=tickets[0].customer,payload={"recipient_mobile":"09120000999"},
status=NotificationJob.Status.SENT,attempts=1,next_attempt_at=timezone.now(),
dedupe_key=f"test-supervisor-{tickets[0].pk}",
)
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db()
assert dispatch.status==GroupDispatch.Status.PARTIAL
client.force_login(staff)
detail=client.get(reverse("staff_tickets:dispatch_detail",args=[dispatch.public_id]))
content=detail.content.decode()
assert detail.status_code==200
assert "قیمت را ثبت کنید." in content
assert "متن اولیه کارشناس" in content
assert "کد -19" in content and "شارژ پنل" in content
assert "جزئیات فنی و رهگیری" in content
assert jalali_datetime(dispatch.created_at) in content
assert dispatch.created_at.strftime("%Y/%m/%d %H:%M") not in content
assert "0 + 0" not in content
assert "مشاهده نشده" in content
assert detail.context["tickets"][0].sms_job.pk==job.pk
assert detail.context["metrics"]["failed"]==1
ticket_detail=client.get(reverse("staff_tickets:detail",args=[tickets[0].public_id]))
assert ticket_detail.status_code==200
assert "علت دقیق" in ticket_detail.content.decode() and "کد -19" in ticket_detail.content.decode()
client.force_login(outsider)
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==403
client.force_login(staff)
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==302
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id]),{"job_id":job.pk}).status_code==302
job.refresh_from_db(); dispatch.refresh_from_db()
assert (job.status,job.attempts,job.last_error)==(NotificationJob.Status.PENDING,0,"")
assert job.provider_status_code=="" and job.delivery_status==""
assert dispatch.status==GroupDispatch.Status.QUEUED
@pytest.mark.django_db
def test_provisional_delivery_is_tracking_not_failure_or_manual_retry(client):
unit=OrganizationalUnit.objects.create(name="پیگیری دلیوری",slug="delivery-tracking-unit")
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets",codename__in=("view_ticket","dispatch_group_tickets")))
staff=make_staff("delivery-tracking-agent",unit)
contact=make_contact("delivery-tracking-contact","09120000322")
group=ContactGroup.objects.create(name="گروه پیگیری",unit=unit); contact.customer_profile.groups.add(group)
category=TicketCategory.objects.create(unit=unit,title="پیگیری",slug="delivery-tracking-category")
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="پیگیری تحویل",body="متن")
job=NotificationJob.objects.get(ticket=tickets[0],event_type="STAFF_TICKET_CREATED")
NotificationJob.objects.filter(pk=job.pk).update(
status=NotificationJob.Status.SENT,attempts=1,sent_at=timezone.now(),provider_message_id="123456789",
delivery_status=NotificationJob.DeliveryStatus.UNKNOWN,delivery_status_code="8",
delivery_next_check_at=timezone.now()+timedelta(minutes=3),delivery_check_attempts=1,
)
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db()
assert dispatch.status==GroupDispatch.Status.SENT
client.force_login(staff)
detail=client.get(reverse("staff_tickets:dispatch_detail",args=[dispatch.public_id]))
assert detail.context["metrics"]["tracking"]==1 and detail.context["metrics"]["failed"]==0
assert detail.context["tickets"][0].sms_retryable is False
client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id]),{"job_id":job.pk})
job.refresh_from_db()
assert job.status==NotificationJob.Status.SENT and job.delivery_status==NotificationJob.DeliveryStatus.UNKNOWN
@pytest.mark.django_db
def test_magic_link_can_be_revoked_only_with_unit_permission(client):
unit=OrganizationalUnit.objects.create(name="حسابرسی",slug="audit-unit")
+34 -2
View File
@@ -6,7 +6,7 @@ from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import User
from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import TicketCategory
from apps.tickets.models import Ticket, TicketCategory
from apps.tickets.services import create_ticket, claim_ticket, reply_ticket
from apps.messaging.models import Message,MessageReadReceipt
from apps.customers.models import CustomerProfile
@@ -112,10 +112,42 @@ def test_new_ticket_page_has_expert_cards_voice_and_file_preview(client):
client.force_login(customer)
content = client.get(reverse("tickets:new"),{"unit":unit.pk}).content.decode()
assert "کارشناس نمونه" in content
assert "data-voice-recorder" in content
assert "data-ticket-voice-recorder" in content
assert "data-file-preview" in content
assert "data-ticket-wizard" in content
assert "customer-ticket-wizard.js" in content
assert reverse("tickets:expert_avatar", args=[expert.public_id]) in content
@pytest.mark.django_db
def test_customer_ticket_wizard_keeps_server_validation_and_submitted_values(client):
unit = OrganizationalUnit.objects.create(name="واحد اعتبارسنجی مرحله‌ای", slug="wizard-validation-unit")
category = TicketCategory.objects.create(unit=unit, title="دسته مرحله‌ای", slug="wizard-validation-category")
customer = User.objects.create_user(
username="wizard-validation-customer",
mobile="09126666001",
account_type="CUSTOMER",
)
client.force_login(customer)
response = client.post(
reverse("tickets:new"),
{
"unit": unit.pk,
"category": category.pk,
"subject": "موضوع حفظ‌شده پس از خطای سرور",
"province": "",
"city": "",
"selected_expert": "",
"body": "",
},
)
assert response.status_code == 200
assert "متن یا پیوست الزامی است." in response.context["form"].non_field_errors()
content = response.content.decode()
assert "data-step-server-errors" in content
assert "موضوع حفظ‌شده پس از خطای سرور" in content
assert not Ticket.objects.filter(subject="موضوع حفظ‌شده پس از خطای سرور").exists()
@pytest.mark.django_db
def test_profile_location_prefills_ticket_but_can_be_overridden_per_ticket(client):
unit=OrganizationalUnit.objects.create(name="فروش تست موقعیت",slug="location-sales")