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"
{label}
" 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'' 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 '