664 lines
45 KiB
Python
664 lines
45 KiB
Python
import pytest
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from django.contrib.auth.models import Permission
|
|
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 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
|
|
from apps.core.persian_text import normalize_persian_text
|
|
|
|
@pytest.mark.django_db
|
|
def test_customer_detail_is_forbidden_to_other_customer(client):
|
|
category = TicketCategory.objects.create(title="قیمت", slug="test-detail-price")
|
|
owner = User.objects.create_user(username="one", mobile="09121111111", account_type="CUSTOMER")
|
|
other = User.objects.create_user(username="two", mobile="09122222222", account_type="CUSTOMER")
|
|
ticket = create_ticket(customer=owner, category=category, subject="استعلام محصول", body="توضیح")
|
|
client.force_login(other)
|
|
assert client.get(reverse("tickets:detail", args=[ticket.public_id])).status_code == 403
|
|
|
|
@pytest.mark.django_db
|
|
def test_locked_public_ticket_only_leaks_metadata(client):
|
|
unit = OrganizationalUnit.objects.create(name="فروش قفلشده", slug="locked-sales")
|
|
unit.permissions.add(
|
|
Permission.objects.get(content_type__app_label="tickets", codename="view_ticket")
|
|
)
|
|
category = TicketCategory.objects.create(unit=unit, title="قیمت", slug="test-locked-price")
|
|
customer = User.objects.create_user(username="customer", mobile="09123333333", account_type="CUSTOMER")
|
|
expert = User.objects.create_user(username="expert", mobile="09124444444", account_type="STAFF")
|
|
observer = User.objects.create_user(username="observer", mobile="09125555555", account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert, primary_unit=unit, display_name="کارشناس اول")
|
|
StaffProfile.objects.create(user=observer, primary_unit=unit, display_name="کارشناس دوم")
|
|
for user in (expert, observer):
|
|
for code in ("view_ticket", "claim_ticket"):
|
|
user.user_permissions.add(Permission.objects.get(content_type__app_label="tickets", codename=code))
|
|
ticket = create_ticket(customer=customer, category=category, subject="موضوع قابل مشاهده", body="محتوای محرمانه")
|
|
claim_ticket(ticket.pk, expert)
|
|
client.force_login(observer)
|
|
listing = client.get(reverse("staff_tickets:list"))
|
|
assert normalize_persian_text("موضوع قابل مشاهده") not in listing.content.decode()
|
|
assert "محتوای محرمانه" not in listing.content.decode()
|
|
assert client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).status_code == 403
|
|
|
|
@pytest.mark.django_db
|
|
@pytest.mark.parametrize("tab,label",[("","میز کار"),("new","صف جدید"),("waiting_staff","در انتظار اقدام من"),("mine","تیکتهای من"),("shared","اشتراکها")])
|
|
def test_staff_sidebar_marks_only_current_queue_active(client,tab,label):
|
|
expert=User.objects.create_user(username=f"nav-{tab or 'desk'}",mobile=f"0913{len(tab):07d}",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,display_name="کارشناس ناوبری")
|
|
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
|
client.force_login(expert)
|
|
response=client.get(reverse("staff_tickets:list"),{"tab":tab} if tab else {})
|
|
content=response.content.decode()
|
|
assert response.status_code==200
|
|
assert content.count('aria-current="page"')==2 # desktop sidebar + mobile dock
|
|
expected=reverse("staff_tickets:list")+(f"?tab={tab}" if tab else "")
|
|
assert f'class="active" href="{expected}" aria-current="page"' in content
|
|
assert f"<h1>{label}</h1>" in content
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_filters_keep_selected_values_and_active_tab(client):
|
|
category=TicketCategory.objects.create(title="فروش سازمانی",slug="enterprise-filter")
|
|
expert=User.objects.create_user(username="filter-expert",mobile="09130000009",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,display_name="کارشناس فیلتر")
|
|
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
|
client.force_login(expert)
|
|
content=client.get(reverse("staff_tickets:list"),{"tab":"mine","category":category.pk,"owner":expert.pk,"unread":"1"}).content.decode()
|
|
assert f'<input type="hidden" name="tab" value="mine">' in content
|
|
assert f'value="{category.pk}" selected' in content
|
|
assert f'value="{expert.pk}" selected' in content
|
|
assert 'name="unread" value="1" checked' in content
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_desk_prioritizes_fresh_actionable_activity_and_renders_distinct_statuses(client):
|
|
unit=OrganizationalUnit.objects.create(name="فروش اولویت",slug="priority-sales")
|
|
unit.permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
|
category=TicketCategory.objects.create(unit=unit,title="پیگیری فروش",slug="staff-priority-order")
|
|
customer=User.objects.create_user(username="priority-customer",mobile="09130000010",account_type="CUSTOMER")
|
|
CustomerProfile.objects.create(user=customer,first_name="مشتری",last_name="نمونه")
|
|
expert=User.objects.create_user(username="priority-expert",mobile="09130000011",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,primary_unit=unit,display_name="کارشناس اولویت")
|
|
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
|
waiting_customer=create_ticket(customer=customer,category=category,subject="منتظر پاسخ مشتری",body="شرح اول")
|
|
fresh_customer_reply=create_ticket(customer=customer,category=category,subject="پاسخ تازه مشتری",body="شرح دوم")
|
|
new_ticket=create_ticket(customer=customer,category=category,subject="تیکت تازه ثبت شده",body="شرح سوم")
|
|
older_urgent=create_ticket(customer=customer,category=category,subject="تیکت فوری قدیمی",body="شرح چهارم")
|
|
now=timezone.now()
|
|
type(waiting_customer).objects.filter(pk=waiting_customer.pk).update(status="WAITING_CUSTOMER",last_activity_at=now+timedelta(hours=1))
|
|
type(fresh_customer_reply).objects.filter(pk=fresh_customer_reply.pk).update(status="WAITING_STAFF",last_activity_at=now)
|
|
type(new_ticket).objects.filter(pk=new_ticket.pk).update(status="NEW",last_activity_at=now-timedelta(minutes=1))
|
|
type(older_urgent).objects.filter(pk=older_urgent.pk).update(status="WAITING_STAFF",priority="URGENT",last_activity_at=now-timedelta(days=1))
|
|
client.force_login(expert)
|
|
response=client.get(reverse("staff_tickets:list"))
|
|
content=response.content.decode()
|
|
assert [ticket.pk for ticket in response.context["tickets"][:4]]==[
|
|
fresh_customer_reply.pk,new_ticket.pk,older_urgent.pk,waiting_customer.pk,
|
|
]
|
|
assert "status-row-waiting_staff" in content and "status-waiting_staff" in content
|
|
assert "status-row-waiting_customer" in content and "status-waiting_customer" in content
|
|
assert "action-required" in content
|
|
assert "جستوجو و فیلتر تیکتها" in content
|
|
|
|
@pytest.mark.django_db
|
|
def test_new_ticket_page_has_expert_cards_voice_and_file_preview(client):
|
|
unit=OrganizationalUnit.objects.create(name="فروش نمایش کارشناس",slug="expert-display-sales")
|
|
TicketCategory.objects.create(unit=unit,title="قیمت", slug="test-expert-display-price")
|
|
customer = User.objects.create_user(username="new-customer", mobile="09126666666", account_type="CUSTOMER")
|
|
expert = User.objects.create_user(username="selectable", mobile="09127777777", account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert, primary_unit=unit, display_name="کارشناس نمونه", is_customer_selectable=True)
|
|
client.force_login(customer)
|
|
content = client.get(reverse("tickets:new"),{"unit":unit.pk}).content.decode()
|
|
assert "کارشناس نمونه" 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")
|
|
category=TicketCategory.objects.create(unit=unit,title="فروش",slug="location-sale")
|
|
customer=User.objects.create_user(username="located-customer",mobile="09121110000",account_type="CUSTOMER")
|
|
CustomerProfile.objects.create(user=customer,province="تهران",city="تهران")
|
|
client.force_login(customer)
|
|
new_page=client.get(reverse("tickets:new"))
|
|
content=new_page.content.decode()
|
|
assert 'data-location-picker' in content
|
|
assert 'value="تهران"' in content
|
|
assert 'data/iran-locations.json' in content
|
|
response=client.post(reverse("tickets:new"),{"unit":unit.pk,"category":category.pk,"subject":"درخواست ارسال به مقصد","province":"گیلان","city":"رشت","selected_expert":"","body":"شرح کامل درخواست برای مقصد جدید"})
|
|
assert response.status_code==302
|
|
ticket=customer.customer_tickets.get()
|
|
assert (ticket.province,ticket.city)==("گیلان","رشت")
|
|
customer.customer_profile.refresh_from_db()
|
|
assert (customer.customer_profile.province,customer.customer_profile.city)==("تهران","تهران")
|
|
|
|
@pytest.mark.django_db
|
|
def test_invalid_city_for_province_is_rejected(client):
|
|
category=TicketCategory.objects.create(title="اعتبارسنجی",slug="location-validation")
|
|
customer=User.objects.create_user(username="invalid-location",mobile="09121110001",account_type="CUSTOMER")
|
|
CustomerProfile.objects.create(user=customer)
|
|
client.force_login(customer)
|
|
response=client.post(reverse("tickets:new"),{"category":category.pk,"subject":"بررسی موقعیت نامعتبر","province":"تهران","city":"رشت","selected_expert":"","body":"شرح کافی برای بررسی اعتبارسنجی"})
|
|
assert response.status_code==200
|
|
assert "شهر انتخابشده متعلق به این استان نیست" in response.content.decode()
|
|
assert not customer.customer_tickets.exists()
|
|
|
|
@pytest.mark.django_db
|
|
def test_customer_conversation_workspace_and_message_pagination_are_protected(client):
|
|
category=TicketCategory.objects.create(title="پشتیبانی",slug="support")
|
|
customer=User.objects.create_user(username="conversation-owner",mobile="09120000001",account_type="CUSTOMER")
|
|
other=User.objects.create_user(username="conversation-other",mobile="09120000002",account_type="CUSTOMER")
|
|
ticket=create_ticket(customer=customer,category=category,subject="بررسی تجربه مکالمه",body="پیام آغازین")
|
|
client.force_login(customer)
|
|
detail=client.get(reverse("tickets:detail",args=[ticket.public_id]))
|
|
content=detail.content.decode()
|
|
assert detail.status_code==200
|
|
assert "data-conversation-workspace" in content
|
|
assert "data-conversation-end" in content
|
|
assert "data-ticket-composer" in content
|
|
assert 'data-chat-coach-own' in content
|
|
created_at=ticket.ticket_messages.order_by("created_at").first().created_at
|
|
assert f'data-chat-coach-at="{int(created_at.timestamp())}"' in content
|
|
assert "conversation.js" in content
|
|
assert 'accept="image/*"' in content and "data-gallery-input" in content and "data-document-input" in content
|
|
assert "ticket-company-logo" not in content
|
|
client.force_login(other)
|
|
assert client.get(reverse("tickets:messages",args=[ticket.public_id])).status_code==403
|
|
|
|
@pytest.mark.django_db
|
|
def test_customer_can_submit_multiple_replies_with_success_feedback(client):
|
|
category=TicketCategory.objects.create(title="پیگیری",slug="follow-up")
|
|
customer=User.objects.create_user(username="reply-owner",mobile="09120000003",account_type="CUSTOMER")
|
|
ticket=create_ticket(customer=customer,category=category,subject="پیگیری چند مرحلهای",body="شرح اولیه کامل")
|
|
client.force_login(customer)
|
|
initial=ticket.ticket_messages.count()
|
|
first=client.post(reverse("tickets:reply",args=[ticket.public_id]),{"body":"پاسخ تکمیلی اول"},follow=True)
|
|
assert first.status_code==200
|
|
assert "پاسخ شما با موفقیت در تیکت ثبت شد" in first.content.decode()
|
|
assert "reply_result=reply" in first.redirect_chain[-1][0]
|
|
assert "data-composer-toggle" in first.content.decode()
|
|
assert 'data-reply-result="reply"' in first.content.decode()
|
|
assert 'composer is-collapsed' in first.content.decode()
|
|
second=client.post(reverse("tickets:reply",args=[ticket.public_id]),{"body":"پاسخ تکمیلی دوم"},follow=True)
|
|
assert second.status_code==200
|
|
assert ticket.ticket_messages.count()==initial+2
|
|
|
|
@pytest.mark.django_db
|
|
def test_invalid_reply_shows_error_and_reopens_composer(client):
|
|
category=TicketCategory.objects.create(title="خطا",slug="reply-error")
|
|
customer=User.objects.create_user(username="reply-error-owner",mobile="09120000004",account_type="CUSTOMER")
|
|
ticket=create_ticket(customer=customer,category=category,subject="بررسی خطای پاسخ",body="شرح اولیه")
|
|
client.force_login(customer)
|
|
response=client.post(reverse("tickets:reply",args=[ticket.public_id]),{"body":""},follow=True)
|
|
content=response.content.decode()
|
|
assert response.status_code==200
|
|
assert "پاسخ ثبت نشد" in content
|
|
assert "reply_result=error" in response.redirect_chain[-1][0]
|
|
assert 'data-reply-result="error"' in content
|
|
assert 'composer is-collapsed' not in content
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_reply_feedback_and_new_reply_action_remain_available(client):
|
|
category=TicketCategory.objects.create(title="کارشناس",slug="staff-reply")
|
|
customer=User.objects.create_user(username="staff-reply-customer",mobile="09120000005",account_type="CUSTOMER")
|
|
expert=User.objects.create_user(username="staff-reply-expert",mobile="09120000006",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,display_name="کارشناس پاسخگو")
|
|
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
|
ticket=create_ticket(customer=customer,category=category,subject="پاسخ رسمی کارشناس",selected_expert=expert,body="شرح مشتری")
|
|
client.force_login(expert)
|
|
response=client.post(reverse("staff_tickets:reply",args=[ticket.public_id]),{"body":"پاسخ رسمی و کامل کارشناس"},follow=True)
|
|
content=response.content.decode()
|
|
assert response.status_code==200
|
|
assert "پاسخ رسمی با موفقیت در تیکت ثبت" in content
|
|
assert "reply_result=reply" in response.redirect_chain[-1][0]
|
|
assert "ثبت اقدام جدید در تیکت" in content
|
|
assert 'data-reply-result="reply"' in content
|
|
assert "data-conversation-end" in content
|
|
assert 'composer staff-composer is-collapsed' in content
|
|
assert 'accept="image/*"' in content and "data-gallery-input" in content and "data-document-input" in content
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_ticket_sms_status_is_compact_expandable_and_time_precise(client):
|
|
from apps.core.templatetags.jalali import jalali_datetime
|
|
from apps.notifications.models import NotificationJob
|
|
|
|
category=TicketCategory.objects.create(title="رهگیری پیامک",slug="sms-timeline")
|
|
customer=User.objects.create_user(username="sms-timeline-customer",mobile="09120000031",account_type="CUSTOMER")
|
|
expert=User.objects.create_user(username="sms-timeline-expert",mobile="09120000032",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,display_name="کارشناس رهگیری")
|
|
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
|
ticket=create_ticket(customer=customer,category=category,subject="نمایش دقیق وضعیت پیامک",selected_expert=expert,body="شرح")
|
|
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
|
|
sent_at=timezone.now()-timedelta(minutes=4)
|
|
delivered_at=timezone.now()-timedelta(minutes=1)
|
|
NotificationJob.objects.filter(pk=job.pk).update(
|
|
status=NotificationJob.Status.SENT,
|
|
sent_at=sent_at,
|
|
provider_message_id="123456789",
|
|
delivery_status=NotificationJob.DeliveryStatus.DELIVERED,
|
|
delivery_status_code="5",
|
|
delivery_description="پیامک به گوشی گیرنده رسیده است.",
|
|
delivery_checked_at=delivered_at,
|
|
delivery_next_check_at=None,
|
|
)
|
|
|
|
client.force_login(expert)
|
|
content=client.get(reverse("staff_tickets:detail",args=[ticket.public_id])).content.decode()
|
|
assert 'class="sms-delivery-item"' in content
|
|
assert 'aria-label="نمایش جزئیات زمانبندی پیامک"' in content
|
|
for label in ("ثبت درخواست ارسال","پذیرش توسط درگاه پیامک","تأیید رسیدن به گوشی"):
|
|
assert label in content
|
|
assert jalali_datetime(sent_at) in content
|
|
assert jalali_datetime(delivered_at) in content
|
|
assert "123456789" in content
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_sidebar_hides_admin_link_and_shows_developer_credit(client):
|
|
expert=User.objects.create_superuser(username="sidebar-admin",mobile="09384999655",password="test-pass",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,display_name="مدیر آزمایشی")
|
|
client.force_login(expert)
|
|
content=client.get(reverse("staff_tickets:list")).content.decode()
|
|
assert "مدیریت سامانه" not in content
|
|
assert "برنامه نویسی و توسعه: محمد جواد فصاحت" in content
|
|
assert "09202202129" in content
|
|
assert "واحد فناوری اطلاعات" not in content
|
|
assert "الگوهای پیامک" not in content
|
|
assert reverse("staff_tickets:sms_templates") not in content
|
|
|
|
@pytest.mark.django_db
|
|
def test_root_redirects_to_login_or_authenticated_workspace(client):
|
|
response = client.get("/")
|
|
assert response.status_code == 302
|
|
assert response.url == reverse("accounts:login")
|
|
|
|
customer = User.objects.create_user(
|
|
username="root-customer", mobile="09120000991", account_type="CUSTOMER"
|
|
)
|
|
client.force_login(customer)
|
|
assert client.get("/").url == reverse("tickets:list")
|
|
|
|
staff = User.objects.create_user(
|
|
username="root-staff", mobile="09120000992", account_type="STAFF"
|
|
)
|
|
StaffProfile.objects.create(user=staff, display_name="کارشناس ریشه")
|
|
client.force_login(staff)
|
|
assert client.get("/").url == reverse("staff_tickets:list")
|
|
|
|
@pytest.mark.django_db
|
|
def test_admin_is_only_mounted_at_private_path(client):
|
|
assert reverse("admin:index") == "/x5admin/"
|
|
assert client.get("/admin/").status_code == 404
|
|
|
|
@pytest.mark.django_db
|
|
def test_customer_can_set_password_and_login(client):
|
|
customer=User.objects.create_user(username="password-user",mobile="09128888888",account_type="CUSTOMER")
|
|
CustomerProfile.objects.create(user=customer)
|
|
client.force_login(customer)
|
|
response=client.post(reverse("accounts:profile"),{"first_name":"علی","last_name":"رضایی","customer_type":"PERSON","province":"خراسان رضوی","city":"مشهد","password":"strong-pass-123"})
|
|
assert response.status_code==302
|
|
customer.customer_profile.refresh_from_db()
|
|
assert (customer.customer_profile.province,customer.customer_profile.city)==("خراسان رضوی","مشهد")
|
|
client.logout()
|
|
response=client.post(reverse("accounts:password_login"),{"mobile":"09128888888","password":"strong-pass-123"})
|
|
assert response.status_code==302 and response.url==reverse("tickets:list")
|
|
|
|
@pytest.mark.django_db
|
|
def test_magic_link_is_reusable_for_seven_days_and_only_grants_one_ticket(client):
|
|
from apps.accounts.models import TicketMagicLink
|
|
category=TicketCategory.objects.create(title="فروش",slug="sale")
|
|
customer=User.objects.create_user(username="magic-user",mobile="09129999999",account_type="CUSTOMER")
|
|
ticket=create_ticket(customer=customer,category=category,subject="ورود جادویی",body="متن")
|
|
other_ticket=create_ticket(customer=customer,category=category,subject="تیکت دیگر",body="متن دوم")
|
|
token=TicketMagicLink.issue(ticket)
|
|
url=reverse("accounts:magic",args=[token])
|
|
response=client.get(url)
|
|
assert response.status_code==302 and response.url==reverse("tickets:detail",args=[ticket.public_id])
|
|
assert "_auth_user_id" not in client.session
|
|
detail=client.get(response.url)
|
|
assert detail.status_code==200 and "فقط به همین تیکت دسترسی دارید" in detail.content.decode()
|
|
assert client.get(reverse("tickets:poll",args=[ticket.public_id])).status_code==200
|
|
assert client.get(reverse("tickets:list")).status_code==302
|
|
assert client.get(reverse("tickets:new")).status_code==302
|
|
assert client.get(reverse("accounts:profile")).status_code==302
|
|
assert client.get(reverse("tickets:detail",args=[other_ticket.public_id])).status_code==302
|
|
reply=client.post(reverse("tickets:reply",args=[ticket.public_id]),{"body":"پاسخ از لینک امن"})
|
|
assert reply.status_code==302
|
|
assert ticket.ticket_messages.filter(author=customer,body="پاسخ از لینک امن").exists()
|
|
client.session.flush()
|
|
repeated=client.get(url)
|
|
assert repeated.status_code==302 and repeated.url==reverse("tickets:detail",args=[ticket.public_id])
|
|
|
|
@pytest.mark.django_db
|
|
def test_ticket_polling_is_versioned_incremental_and_permission_safe(client):
|
|
unit=OrganizationalUnit.objects.create(name="فروش Polling",slug="polling-sales")
|
|
unit.permissions.add(
|
|
Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"),
|
|
Permission.objects.get(content_type__app_label="messaging",codename="add_message"),
|
|
)
|
|
category=TicketCategory.objects.create(unit=unit,title="Polling",slug="polling")
|
|
customer=User.objects.create_user(username="poll-customer",mobile="09129990001",account_type="CUSTOMER")
|
|
expert=User.objects.create_user(username="poll-expert",mobile="09129990002",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,primary_unit=unit,display_name="کارشناس پولینگ")
|
|
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
|
|
ticket=create_ticket(customer=customer,category=category,subject="بررسی پاسخ تازه",body="پیام نخست")
|
|
client.force_login(customer)
|
|
detail=client.get(reverse("tickets:detail",args=[ticket.public_id]))
|
|
content=detail.content.decode()
|
|
assert reverse("tickets:poll",args=[ticket.public_id]) in content
|
|
assert 'data-poll-version=' in content and 'class="composer-send-icon" role="button" tabindex="0" data-scroll-to-submit' in content
|
|
assert '<button class="composer-send-icon"' not in content
|
|
assert 'type="submit" data-primary-submit' in content
|
|
initial=client.get(reverse("tickets:poll",args=[ticket.public_id])).json()
|
|
unchanged=client.get(reverse("tickets:poll",args=[ticket.public_id]),{"version":initial["version"],"cursor":initial["cursor"]}).json()
|
|
assert initial["changed"] is True and unchanged=={"changed":False,"version":initial["version"]}
|
|
reply_ticket(ticket_id=ticket.pk,user=expert,body="پاسخ تازه کارشناس")
|
|
changed=client.get(reverse("tickets:poll",args=[ticket.public_id]),{"version":initial["version"],"cursor":initial["cursor"]}).json()
|
|
assert changed["changed"] is True and changed["replace"] is False
|
|
assert "پاسخ تازه کارشناس" in changed["html"] and changed["cursor"]!=initial["cursor"]
|
|
staff_message=Message.objects.get(ticket=ticket,body="پاسخ تازه کارشناس")
|
|
assert not MessageReadReceipt.objects.filter(message=staff_message,user=customer).exists()
|
|
read=client.post(reverse("tickets:read",args=[ticket.public_id]),data=f'{{"message_ids":["{staff_message.public_id}"]}}',content_type="application/json")
|
|
assert read.status_code==200 and read.json()["ok"] is True
|
|
assert MessageReadReceipt.objects.filter(message=staff_message,user=customer).exists()
|
|
sms=staff_message.ticket.notificationjob_set.get(event_type="STAFF_REPLY")
|
|
assert sms.status=="CANCELLED" and sms.last_error=="customer_read"
|
|
assert client.get(reverse("tickets:poll",args=[ticket.public_id]))["Cache-Control"]=="private, no-cache, no-store, must-revalidate"
|
|
client.force_login(expert)
|
|
staff_detail=client.get(reverse("staff_tickets:detail",args=[ticket.public_id])).content.decode()
|
|
assert reverse("staff_tickets:poll",args=[ticket.public_id]) in staff_detail and 'class="composer-send-icon" role="button" tabindex="0" data-scroll-to-submit' in staff_detail
|
|
assert '<button class="composer-send-icon"' not in staff_detail
|
|
assert 'type="submit" data-primary-submit' in staff_detail
|
|
Message.objects.create(ticket=ticket,author=expert,body="یادداشت داخلی تازه",is_internal=True,message_type=Message.Type.INTERNAL_NOTE)
|
|
staff_poll=client.get(reverse("staff_tickets:poll",args=[ticket.public_id])).json()
|
|
assert "یادداشت داخلی تازه" in staff_poll["html"]
|
|
staff_baseline=client.get(reverse("staff_tickets:poll",args=[ticket.public_id])).json()
|
|
reply_ticket(ticket_id=ticket.pk,user=customer,body="پاسخ مشتری برای پولینگ پنل کارشناسان")
|
|
staff_changed=client.get(
|
|
reverse("staff_tickets:poll",args=[ticket.public_id]),
|
|
{"version":staff_baseline["version"],"cursor":staff_baseline["cursor"]},
|
|
).json()
|
|
assert staff_changed["changed"] is True and staff_changed["replace"] is False
|
|
assert "پاسخ مشتری برای پولینگ پنل کارشناسان" in staff_changed["html"]
|
|
assert staff_changed["cursor"]!=staff_baseline["cursor"]
|
|
|
|
# A restored browser page may retain the newest version while its DOM/cursor
|
|
# is stale. The server must resend the missing fragment in that situation.
|
|
stale_cursor_retry=client.get(
|
|
reverse("staff_tickets:poll",args=[ticket.public_id]),
|
|
{"version":staff_changed["version"],"cursor":staff_baseline["cursor"]},
|
|
).json()
|
|
assert stale_cursor_retry["changed"] is True
|
|
assert "پاسخ مشتری برای پولینگ پنل کارشناسان" in stale_cursor_retry["html"]
|
|
|
|
def test_ticket_polling_javascript_keeps_background_sync_and_recovers_reliably():
|
|
script=Path("static/js/conversation.js").read_text(encoding="utf-8")
|
|
for feature in ("visibilitychange","document.visibilityState==='visible'","AbortController","pollRunning","nextPollAt","pollHeartbeat","setInterval(startPolling,1000)","cache:'no-store'","const restartPolling=()=>{stopPolling();startPolling()}","window.addEventListener('pageshow',restartPolling)","window.addEventListener('online',restartPolling)","data-scroll-to-submit","scrollIntoView","submit-attention","keepLatestVisible","requestAnimationFrame"):
|
|
assert feature in script
|
|
assert "pollUrl&&navigator.onLine" not in script
|
|
assert "window.addEventListener('offline',stopPolling)" not in script
|
|
assert "&&document.hasFocus()" not in script
|
|
assert "navigator.onLine&&document.visibilityState==='visible'" not in script
|
|
assert "window.addEventListener('blur',stopPolling)" not in script
|
|
assert "conversationEnd?.scrollIntoView" not in script
|
|
assert "scroller.scrollTo({top,behavior:'smooth'})" in script
|
|
assert "data-message-id" in Path("templates/tickets/_messages.html").read_text(encoding="utf-8")
|
|
assert "acknowledgeVisibleMessages" in script and "document.visibilityState!=='visible'" in script
|
|
source_css=Path("static/src/tailwind.css").read_text(encoding="utf-8")
|
|
assert ".staff-ticket-workspace .composer.is-collapsed{position:fixed" not in source_css
|
|
assert ".staff-ticket-workspace .composer:not(.is-collapsed){position:fixed" not in source_css
|
|
assert "max-height:min(45dvh,28rem)" in source_css
|
|
for menu_feature in ("data-message-menu","data-message-menu-close","closeMessageMenu","event.key==='Escape'","!menu.contains(event.target)"):
|
|
assert menu_feature in script or menu_feature in Path("templates/tickets/_messages.html").read_text(encoding="utf-8")
|
|
|
|
def test_frontend_runtime_libraries_are_local_and_versioned():
|
|
template=Path("templates/base.html").read_text(encoding="utf-8")
|
|
assert "unpkg.com" not in template
|
|
for asset in ("vendor/htmx-2.0.3.min.js","vendor/alpine-3.14.8.min.js"):
|
|
assert asset in template and Path("static",asset).is_file()
|
|
assert template.count("?v={{ static_asset_version }}")>=2
|
|
|
|
def test_chat_coach_is_private_non_blocking_and_enabled_for_customer_and_staff_forms():
|
|
script=Path("static/js/chat-coach.js").read_text(encoding="utf-8")
|
|
for feature in ("readLastTwoMessageTimes","rapidWindowMs","data-chat-coach-dismiss","MutationObserver"):
|
|
assert feature in script
|
|
assert "localStorage" not in script
|
|
assert "preventDefault" not in script and "form.addEventListener('submit'" not in script
|
|
assert "slice(-2)" in script and "last-previous<=rapidWindowMs" in script
|
|
assert "document.body.append(hint)" in script
|
|
for template in ("templates/tickets/customer_new.html","templates/tickets/customer_detail.html","templates/tickets/staff_detail.html"):
|
|
assert "data-chat-coach" in Path(template).read_text(encoding="utf-8")
|
|
assert "data-chat-coach-own" in Path("templates/tickets/_messages.html").read_text(encoding="utf-8")
|
|
assert "js/chat-coach.js" in Path("templates/base.html").read_text(encoding="utf-8")
|
|
|
|
@pytest.mark.django_db
|
|
def test_expired_or_invalid_magic_link_shows_mobile_login_guidance(client):
|
|
from datetime import timedelta
|
|
from django.utils import timezone
|
|
from apps.accounts.models import TicketMagicLink
|
|
category=TicketCategory.objects.create(title="فروش",slug="expired-magic")
|
|
customer=User.objects.create_user(username="expired-magic-user",mobile="09202202129",account_type="CUSTOMER")
|
|
ticket=create_ticket(customer=customer,category=category,subject="لینک منقضی",body="متن")
|
|
token=TicketMagicLink.issue(ticket)
|
|
TicketMagicLink.objects.filter(token_hash=TicketMagicLink.digest(token)).update(expires_at=timezone.now()-timedelta(seconds=1))
|
|
response=client.get(reverse("accounts:magic",args=[token]),follow=True)
|
|
content=response.content.decode()
|
|
assert response.redirect_chain[-1][0]==reverse("accounts:login")
|
|
assert "این لینک منقضی شده یا اشتباه است" in content
|
|
assert "با شماره موبایل خود وارد شوید" in content
|
|
invalid=client.get(reverse("accounts:magic",args=["invalid-token"]),follow=True)
|
|
assert "این لینک منقضی شده یا اشتباه است" in invalid.content.decode()
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_can_create_ticket_for_unregistered_mobile(client):
|
|
from apps.notifications.models import NotificationJob
|
|
category=TicketCategory.objects.create(title="فروش مستقیم",slug="staff-created")
|
|
expert=User.objects.create_user(username="creator",mobile="09127770000",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,display_name="کارشناس سازنده")
|
|
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="add_ticket"))
|
|
client.force_login(expert)
|
|
response=client.post(reverse("staff_tickets:create"),{"customer_mobile":"09128880000","first_name":"علي","last_name":"رضايي","category":category.pk,"subject":"پیگیری فروش جدید","province":"تهران","city":"تهران","body":"توضیح کامل کارشناس"})
|
|
customer=User.objects.get(mobile="+989128880000"); ticket=customer.customer_tickets.get()
|
|
assert response.status_code==302 and response.url==reverse("staff_tickets:detail",args=[ticket.public_id])
|
|
assert ticket.owner==expert and ticket.status==ticket.Status.WAITING_CUSTOMER
|
|
assert customer.customer_profile.first_name=="علی"
|
|
assert NotificationJob.objects.filter(ticket=ticket,event_type="STAFF_TICKET_CREATED",customer=customer).exists()
|
|
|
|
@pytest.mark.django_db
|
|
def test_otp_page_contains_webotp_and_pwa_assets(client,settings):
|
|
from apps.accounts.models import OTPChallenge
|
|
challenge,_=OTPChallenge.issue("09123334444"); session=client.session; session["otp_challenge_id"]=challenge.pk; session.save()
|
|
response=client.get(reverse("accounts:otp")); content=response.content.decode()
|
|
assert 'autocomplete="one-time-code"' in content and "js/webotp.js" in content
|
|
assert 'rel="manifest" href="/manifest.webmanifest?v=' in content
|
|
|
|
@pytest.mark.django_db
|
|
def test_authenticated_users_are_redirected_away_from_all_login_pages(client):
|
|
customer=User.objects.create_user(username="already-customer",mobile="09202202129",account_type="CUSTOMER")
|
|
client.force_login(customer)
|
|
for route in ("accounts:login","accounts:password_login","accounts:staff_login"):
|
|
response=client.get(reverse(route))
|
|
assert response.status_code==302 and response.url==reverse("tickets:list")
|
|
staff=User.objects.create_user(username="already-staff",mobile="09384999655",account_type="STAFF")
|
|
StaffProfile.objects.create(user=staff,display_name="کارشناس واردشده")
|
|
client.force_login(staff)
|
|
for route in ("accounts:login","accounts:password_login","accounts:staff_login"):
|
|
response=client.get(reverse(route))
|
|
assert response.status_code==302 and response.url==reverse("staff_tickets:list")
|
|
|
|
@pytest.mark.django_db
|
|
def test_pwa_prompt_is_mobile_only_and_uses_square_app_icon(client):
|
|
from pathlib import Path
|
|
script=Path("static/js/pwa.js").read_text(encoding="utf-8")
|
|
manifest=client.get(reverse("web_manifest")).json()
|
|
assert "mobileDevice" in script and "banner&&mobileDevice" in script
|
|
assert all(icon["src"].endswith("pwa-icon.png") for icon in manifest["icons"])
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_pwa_install_prompt_is_only_rendered_on_customer_ticket_list(client):
|
|
customer=User.objects.create_user(username="pwa-list-only",mobile="09120000123",account_type="CUSTOMER")
|
|
client.force_login(customer)
|
|
assert 'data-pwa-install' in client.get(reverse("tickets:list")).content.decode()
|
|
assert 'data-pwa-install' not in client.get(reverse("tickets:new")).content.decode()
|
|
assert 'data-pwa-install' not in Path("templates/base.html").read_text(encoding="utf-8")
|
|
|
|
@pytest.mark.django_db
|
|
def test_customer_list_recommends_profile_completion_without_blocking_ticket_creation(client):
|
|
customer=User.objects.create_user(username="incomplete-profile",mobile="09202202129",account_type="CUSTOMER")
|
|
CustomerProfile.objects.create(user=customer,first_name="محمد")
|
|
client.force_login(customer)
|
|
content=client.get(reverse("tickets:list")).content.decode()
|
|
assert "تکمیل پروفایل، برای پیگیری دقیقتر" in content
|
|
assert "نام خانوادگی، استان، شهر" in content
|
|
assert reverse("accounts:profile") in content and reverse("tickets:new") in content
|
|
assert 'href="#recent-requests"' in content
|
|
assert 'id="recent-requests"' in content
|
|
assert "درخواستهای قبلی" in content
|
|
profile=customer.customer_profile; profile.last_name="رضایی"; profile.province="تهران"; profile.city="تهران"; profile.save()
|
|
assert "تکمیل پروفایل، برای پیگیری دقیقتر" not in client.get(reverse("tickets:list")).content.decode()
|
|
|
|
@pytest.mark.django_db
|
|
def test_logout_requires_post_and_clears_session(client):
|
|
customer=User.objects.create_user(username="logout-user",mobile="09125556666")
|
|
client.force_login(customer)
|
|
assert client.get(reverse("accounts:logout")).status_code==405
|
|
response=client.post(reverse("accounts:logout"))
|
|
assert response.status_code==302 and response.url==reverse("accounts:login")
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_customer_directory_search_and_ticket_stats(client):
|
|
from django.contrib.auth.models import Permission
|
|
from apps.customers.models import ContactGroup
|
|
from apps.staff.models import OrganizationalUnit
|
|
category=TicketCategory.objects.create(title="مشتریان",slug="customer-directory")
|
|
customer=User.objects.create_user(username="directory-customer",mobile="09121234567",account_type="CUSTOMER")
|
|
profile=CustomerProfile.objects.create(user=customer,first_name="زهرا",last_name="محمدی",province="اصفهان",city="کاشان")
|
|
create_ticket(customer=customer,category=category,subject="درخواست مشتری",body="شرح درخواست")
|
|
expert=User.objects.create_user(username="directory-expert",mobile="09121110000",account_type="STAFF")
|
|
unit=OrganizationalUnit.objects.create(name="فروش مشتریان",slug="customer-sales")
|
|
unit.permissions.add(Permission.objects.get(content_type__app_label="customers",codename="view_customerprofile"))
|
|
StaffProfile.objects.create(user=expert,display_name="کارشناس مشتریان",primary_unit=unit)
|
|
group=ContactGroup.objects.create(name="مشتریان تست",unit=unit); profile.groups.add(group)
|
|
client.force_login(expert)
|
|
legacy=client.get(reverse("staff_tickets:customers"),{"q":"زهرا"})
|
|
assert legacy.status_code==302 and legacy.url==f'{reverse("staff_tickets:contacts")}?q=%D8%B2%D9%87%D8%B1%D8%A7'
|
|
response=client.get(legacy.url)
|
|
content=response.content.decode()
|
|
assert response.status_code==200 and "زهرا محمدی" in content and "09121234567" in content
|
|
assert "کل تیکت" in content and "نیازمند اقدام" in content
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_customer_live_search_returns_profile_data(client):
|
|
customer=User.objects.create_user(username="lookup-customer",mobile="09129876543",account_type="CUSTOMER")
|
|
CustomerProfile.objects.create(user=customer,first_name="علی",last_name="کریمی",province="فارس",city="شیراز")
|
|
expert=User.objects.create_user(username="lookup-expert",mobile="09121110001",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,display_name="کارشناس جستجو")
|
|
client.force_login(expert)
|
|
response=client.get(reverse("staff_tickets:customer_search"),{"q":"09129876543"})
|
|
item=response.json()["results"][0]
|
|
assert item["id"]==str(customer.public_id) and item["name"]=="علی کریمی"
|
|
assert (item["province"],item["city"])==("فارس","شیراز")
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_create_uses_existing_customer_selected_by_public_id(client):
|
|
category=TicketCategory.objects.create(title="انتخاب مشتری",slug="selected-customer")
|
|
customer=User.objects.create_user(username="selected-customer",mobile="09125550000",account_type="CUSTOMER")
|
|
CustomerProfile.objects.create(user=customer,first_name="مریم",last_name="احمدی",province="گیلان",city="رشت")
|
|
expert=User.objects.create_user(username="selected-expert",mobile="09121110002",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,display_name="کارشناس انتخاب")
|
|
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="add_ticket"))
|
|
client.force_login(expert)
|
|
response=client.post(reverse("staff_tickets:create"),{"customer_id":customer.public_id,"customer_mobile":"09125550000","category":category.pk,"subject":"پیگیری مشتری موجود","province":"گیلان","city":"رشت","body":"شرح کامل پیگیری"})
|
|
assert response.status_code==302
|
|
assert customer.customer_tickets.filter(subject="پیگیری مشتری موجود").exists()
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_create_requires_name_for_unknown_mobile(client):
|
|
category=TicketCategory.objects.create(title="مشتری جدید",slug="new-customer-required-name")
|
|
expert=User.objects.create_user(username="validation-expert",mobile="09121110003",account_type="STAFF")
|
|
StaffProfile.objects.create(user=expert,display_name="کارشناس اعتبارسنجی")
|
|
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="add_ticket"))
|
|
client.force_login(expert)
|
|
response=client.post(reverse("staff_tickets:create"),{"customer_mobile":"09126660000","category":category.pk,"subject":"ثبت مشتری بدون نام","body":"شرح درخواست"})
|
|
assert response.status_code==200 and "برای مشتری جدید، نام را وارد کنید" in response.content.decode()
|
|
assert not User.objects.filter(mobile="+989126660000").exists()
|
|
|
|
def test_frontend_assets_are_versioned_and_service_worker_is_not_cached(client):
|
|
page=client.get(reverse("accounts:login"))
|
|
content=page.content.decode()
|
|
assert "/static/css/tailwind.css?v=" in content
|
|
assert "/static/js/ui.js?v=" in content
|
|
assert 'data-static-version="' in content
|
|
assert '/manifest.webmanifest?v=' in content
|
|
assert '/static/images/favicon.ico?v=' in content
|
|
assert '/static/fonts/vazirmatn-arabic-wght-normal.woff2?v=' in content
|
|
worker=client.get("/service-worker.js")
|
|
assert worker.status_code==200
|
|
assert worker["Cache-Control"]=="no-cache, no-store, must-revalidate"
|
|
assert b"sales-ticket-" in worker.content and b"fetch(event.request,{cache:'no-store'})" in worker.content
|
|
assert b".woff2?v=" in worker.content and b"pwa-icon.png?v=" in worker.content
|
|
assert b"if(!isStatic&&!isAvatar){event.respondWith(fetch(event.request,{cache:'no-store'}));return}" in worker.content
|
|
pwa_script=Path("static/js/pwa.js").read_text(encoding="utf-8")
|
|
assert "controllerchange" in pwa_script and "visibilitychange" in pwa_script and "setInterval(updateWorker,60000)" in pwa_script
|
|
assert "document.documentElement.dataset.staticVersion" in pwa_script
|
|
assert "service-worker.js?v=" in pwa_script and "encodeURIComponent(assetVersion)" in pwa_script
|
|
assert "updateViaCache:'none'" in pwa_script
|
|
assert "pwa-worker-reloaded" not in pwa_script
|
|
assert page["Cache-Control"]=="private, no-cache, no-store, must-revalidate"
|
|
|
|
@pytest.mark.django_db
|
|
def test_staff_avatar_admin_upload_and_protected_display(client,tmp_path,settings):
|
|
import base64
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from apps.staff.forms import StaffProfileAdminForm
|
|
settings.MEDIA_ROOT=tmp_path
|
|
expert=User.objects.create_user(username="avatar-expert",mobile="09123330000",account_type="STAFF")
|
|
png=base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
|
form=StaffProfileAdminForm(data={"user":expert.pk,"display_name":"کارشناس تصویری","job_title":"کارشناس ارشد","sort_order":0,"is_active":"on","is_customer_selectable":"on"},files={"avatar":SimpleUploadedFile("expert.png",png,content_type="image/png")})
|
|
assert form.is_valid(),form.errors
|
|
profile=form.save()
|
|
assert profile.avatar.storage.exists(profile.avatar.name)
|
|
customer=User.objects.create_user(username="avatar-customer",mobile="09123330001",account_type="CUSTOMER")
|
|
client.force_login(customer)
|
|
response=client.get(reverse("tickets:expert_avatar",args=[expert.public_id]))
|
|
assert response.status_code==200 and response["Content-Type"]=="image/png"
|
|
assert "no-cache" in response["Cache-Control"]
|
|
client.force_login(expert)
|
|
assert client.get(reverse("staff_tickets:expert_avatar",args=[expert.public_id])).status_code==200
|