Files
customerTicket/tests/test_views.py
T
2026-09-17 10:11:18 +03:30

684 lines
46 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(45svh,max(7rem,calc(var(--visual-viewport-height,100svh) - 18rem)),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_ticket_workspace_uses_mobile_viewport_and_keyboard_safe_layout_contract():
base=Path("templates/base.html").read_text(encoding="utf-8")
source_css=Path("static/src/tailwind.css").read_text(encoding="utf-8")
ui_script=Path("static/js/ui.js").read_text(encoding="utf-8")
conversation_script=Path("static/js/conversation.js").read_text(encoding="utf-8")
magic_template=Path("templates/tickets/customer_detail.html").read_text(encoding="utf-8")
assert "interactive-widget=resizes-content" in base
for feature in ("100svh","--visual-viewport-height","overscroll-behavior:none","display-mode:standalone"):
assert feature in source_css
assert "min-height:32rem" not in source_css
assert "max-height:min(45svh,max(7rem,calc(var(--visual-viewport-height,100svh) - 18rem)),28rem)" in source_css
for feature in ("window.visualViewport","--visual-viewport-height","appviewportchange","orientationchange"):
assert feature in ui_script
assert "layoutObserver?.observe(composer)" in conversation_script
assert "focus({preventScroll:true})" in conversation_script
for feature in ("is-keyboard-active","ensureInputVisible","stableViewportHeight-height>120","data-keyboard-open"):
assert feature in conversation_script or feature in source_css
assert '<footer class="ticket-detail-credit">{{ developer_credit }}</footer>' in magic_template
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 "hasWorkerController=Boolean(navigator.serviceWorker.controller)" 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