fix sms and ticket user frindly
This commit is contained in:
1 parent
1ee83374fd
commit
b8b3aba855
30 files changed
+2220
-119
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user