link view ticket for staff
This commit is contained in:
1 parent
6b546aad76
commit
089db0afa4
13 files changed
+412
-3
No files matched your search
@@ -0,0 +1,15 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class StaffShortLinkPage(BasePage):
|
||||
def open_and_login(self, path, staff, password, ticket):
|
||||
self.goto(path)
|
||||
expect(self.page.get_by_role("heading", name="ورود کارشناسان")).to_be_visible()
|
||||
self.page.locator('input[name="username"]').fill(staff.username)
|
||||
self.page.locator('input[name="password"]').fill(password)
|
||||
self.page.get_by_role("button", name="ورود به میز کار").click()
|
||||
expect(self.page).to_have_url(self.url(reverse("staff_tickets:detail", args=[ticket.public_id])))
|
||||
expect(self.page.get_by_text(ticket.subject, exact=True).first).to_be_visible()
|
||||
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import StaffTicketShortLink, TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
from apps.tickets.short_links import append_supervisor_ticket_link
|
||||
from tests.e2e.pages.staff_short_link import StaffShortLinkPage
|
||||
from tests.e2e.pages.tickets import TicketConversationPage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
def test_supervisor_short_link_login_and_reply(live_server, browser_page):
|
||||
password = "E2e-short-link-password-2026"
|
||||
unit = OrganizationalUnit.objects.create(
|
||||
name="واحد لینک مرورگر", slug="e2e-short-unit", supervisor_sms_ticket_link_enabled=True,
|
||||
)
|
||||
category = TicketCategory.objects.create(unit=unit, title="درخواست مرورگر", slug="e2e-short-category")
|
||||
customer = User.objects.create_user(username="e2e-short-customer", mobile="09121112340", account_type="CUSTOMER")
|
||||
staff = User.objects.create_user(
|
||||
username="e2e-short-staff", mobile="09121112341", password=password, account_type="STAFF",
|
||||
)
|
||||
StaffProfile.objects.create(user=staff, primary_unit=unit, display_name="کارشناس مرورگر")
|
||||
ticket = create_ticket(
|
||||
customer=customer, category=category, selected_expert=staff,
|
||||
subject="درخواست پاسخ از لینک کوتاه", body="پیام مشتری در مرورگر",
|
||||
)
|
||||
append_supervisor_ticket_link(ticket, "اعلان")
|
||||
link = StaffTicketShortLink.objects.get(ticket=ticket)
|
||||
path = reverse("staff_ticket_short_link", args=[link.token])
|
||||
with browser_page() as page:
|
||||
StaffShortLinkPage(page, live_server.url).open_and_login(path, staff, password, ticket)
|
||||
TicketConversationPage(page, live_server.url).send_reply("پاسخ کارشناس از لینک پیامک")
|
||||
@@ -0,0 +1,193 @@
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import TicketMagicLink, User
|
||||
from apps.integrations.sms.base import SMSResult
|
||||
from apps.notifications.management.commands.process_notifications import _send_sms
|
||||
from apps.notifications.models import NotificationJob, SMSTemplate
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import StaffTicketShortLink, TicketCategory
|
||||
from apps.tickets.services import create_ticket, delete_ticket
|
||||
from apps.tickets.short_links import append_supervisor_ticket_link
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def short_link_case(db):
|
||||
unit = OrganizationalUnit.objects.create(
|
||||
name="واحد لینک کوتاه", slug="short-link-unit", supervisor_mobile_1="09121112233",
|
||||
supervisor_mobile_2="09121112234", supervisor_mobile_3="09121112235",
|
||||
supervisor_mobile_4="09121112236", supervisor_mobile_5="09121112237",
|
||||
supervisor_sms_ticket_link_enabled=True,
|
||||
)
|
||||
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="درخواست لینک", slug="short-link-category")
|
||||
customer = User.objects.create_user(username="short-customer", mobile="09121112238", account_type="CUSTOMER")
|
||||
staff = User.objects.create_user(
|
||||
username="short-staff", password="Staff-password-123", mobile="09121112239", account_type="STAFF",
|
||||
)
|
||||
StaffProfile.objects.create(user=staff, primary_unit=unit, display_name="کارشناس لینک")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="موضوع لینک کوتاه", body="متن درخواست")
|
||||
return unit, customer, staff, ticket
|
||||
|
||||
|
||||
def issue_link(ticket):
|
||||
append_supervisor_ticket_link(ticket, "اعلان")
|
||||
link = StaffTicketShortLink.objects.get(ticket=ticket)
|
||||
return link, reverse("staff_ticket_short_link", args=[link.token])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_all_five_supervisors_receive_same_short_link_without_magic_access(short_link_case, monkeypatch, settings):
|
||||
unit, _, _, ticket = short_link_case
|
||||
settings.PUBLIC_BASE_URL = "https://tickets.example.com/"
|
||||
provider = Mock()
|
||||
provider.send_message.return_value = SMSResult(True, "short-link-test")
|
||||
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: provider)
|
||||
SMSTemplate.objects.create(
|
||||
unit=unit, event_type=SMSTemplate.Event.UNIT_SUPERVISOR_NEW_TICKET,
|
||||
title="الگوی لینک کوتاه", body="تیکت {ticket_number}",
|
||||
)
|
||||
jobs = list(NotificationJob.objects.filter(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("pk"))
|
||||
before = timezone.now()
|
||||
for job in jobs:
|
||||
result, magic_link = _send_sms(job)
|
||||
assert result.success and magic_link is None
|
||||
sent = [call.args for call in provider.send_message.call_args_list]
|
||||
link, path = issue_link(ticket)
|
||||
assert before + timedelta(days=7) <= link.expires_at <= timezone.now() + timedelta(days=7)
|
||||
assert re.fullmatch(r"/vt/[a-zA-Z0-9]{10}", path)
|
||||
assert [mobile for mobile, _ in sent] == list(unit.supervisor_mobiles)
|
||||
assert {message for _, message in sent} == {
|
||||
f"تیکت {ticket.ticket_number}\nمشاهده و پاسخ: https://tickets.example.com{path}"
|
||||
}
|
||||
_send_sms(jobs[0])
|
||||
link.refresh_from_db()
|
||||
assert StaffTicketShortLink.objects.filter(ticket=ticket).count() == 1
|
||||
assert not TicketMagicLink.objects.filter(ticket=ticket).exists()
|
||||
assert not ticket.access_grants.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unit_toggle_preserves_plain_sms_and_does_not_issue_link(short_link_case):
|
||||
unit, _, _, ticket = short_link_case
|
||||
unit.supervisor_sms_ticket_link_enabled = False
|
||||
unit.save()
|
||||
ticket.refresh_from_db()
|
||||
assert append_supervisor_ticket_link(ticket, "اعلان") == "اعلان"
|
||||
assert not StaffTicketShortLink.objects.filter(ticket=ticket).exists()
|
||||
assert OrganizationalUnit._meta.get_field("supervisor_sms_ticket_link_enabled").default is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous_login_returns_to_real_ticket_and_can_reply(short_link_case, client):
|
||||
_, _, staff, ticket = short_link_case
|
||||
_, path = issue_link(ticket)
|
||||
response = client.get(path)
|
||||
assert response.status_code == 302
|
||||
assert urlsplit(response.url).path == "/staff/login/"
|
||||
assert parse_qs(urlsplit(response.url).query)["next"] == [path]
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
client.get(response.url)
|
||||
# Destination also survives a failed attempt and a POST without query params.
|
||||
response = client.post("/staff/login/", {"username": staff.username, "password": "incorrect"})
|
||||
assert response.status_code == 200
|
||||
response = client.post("/staff/login/", {"username": staff.username, "password": "Staff-password-123"})
|
||||
assert response.url == path
|
||||
response = client.get(path)
|
||||
detail = reverse("staff_tickets:detail", args=[ticket.public_id])
|
||||
assert response.url == detail
|
||||
assert client.get(detail).status_code == 200
|
||||
response = client.post(reverse("staff_tickets:reply", args=[ticket.public_id]), {"body": "پاسخ از لینک کوتاه"})
|
||||
assert response.status_code == 302
|
||||
assert ticket.ticket_messages.filter(author=staff, body="پاسخ از لینک کوتاه").exists()
|
||||
assert "ticket_magic_link_id" not in client.session
|
||||
assert not ticket.access_grants.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("expired", [False, True])
|
||||
def test_authenticated_staff_redirect_or_expired_link(short_link_case, client, expired):
|
||||
_, _, staff, ticket = short_link_case
|
||||
link, path = issue_link(ticket)
|
||||
if expired:
|
||||
StaffTicketShortLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1))
|
||||
client.force_login(staff)
|
||||
response = client.get(path)
|
||||
assert response.status_code == (404 if expired else 302)
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
if not expired:
|
||||
assert response.url == reverse("staff_tickets:detail", args=[ticket.public_id])
|
||||
else:
|
||||
assert append_supervisor_ticket_link(ticket, "اعلان") == "اعلان"
|
||||
link.refresh_from_db()
|
||||
assert link.expires_at <= timezone.now()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_expiry_is_rechecked_after_login(short_link_case, client):
|
||||
_, _, staff, ticket = short_link_case
|
||||
link, path = issue_link(ticket)
|
||||
client.get(client.get(path).url)
|
||||
StaffTicketShortLink.objects.filter(pk=link.pk).update(expires_at=timezone.now())
|
||||
client.post("/staff/login/", {"username": staff.username, "password": "Staff-password-123"})
|
||||
assert client.get(path).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_short_link_cannot_bypass_customer_or_other_unit_permissions(short_link_case, client):
|
||||
_, customer, _, ticket = short_link_case
|
||||
_, path = issue_link(ticket)
|
||||
client.force_login(customer)
|
||||
assert client.get(path).status_code == 403
|
||||
outsider = User.objects.create_user(username="outsider", mobile="09121112240", account_type="STAFF")
|
||||
other_unit = OrganizationalUnit.objects.create(name="واحد دیگر", slug="short-other-unit")
|
||||
StaffProfile.objects.create(user=outsider, primary_unit=other_unit, display_name="کارشناس دیگر")
|
||||
client.force_login(outsider)
|
||||
assert client.get(path).status_code == 403
|
||||
assert not ticket.access_grants.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_missing_link_deleted_ticket_and_post_are_rejected(short_link_case, client):
|
||||
_, _, staff, ticket = short_link_case
|
||||
_, path = issue_link(ticket)
|
||||
client.force_login(staff)
|
||||
assert client.get("/vt/unknown").status_code == 404
|
||||
assert client.post(path).status_code == 405
|
||||
staff.is_superuser = True
|
||||
staff.save()
|
||||
delete_ticket(ticket_id=ticket.pk, user=staff)
|
||||
assert client.get(path).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("target", ["https://evil.example/vt/abc", "//evil.example/vt/abc", "/\\evil.example/vt/abc", "/login/", "/staff/login/"])
|
||||
def test_staff_login_rejects_unsafe_or_unrelated_next(short_link_case, client, target):
|
||||
_, _, staff, _ = short_link_case
|
||||
response = client.post("/staff/login/", {
|
||||
"username": staff.username, "password": "Staff-password-123", "next": target,
|
||||
})
|
||||
assert response.url == reverse("staff_tickets:list")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unit_admin_exposes_toggle(short_link_case, client):
|
||||
unit, _, staff, _ = short_link_case
|
||||
staff.is_superuser = staff.is_staff = True
|
||||
staff.save()
|
||||
client.force_login(staff)
|
||||
response = client.get(reverse("admin:staff_organizationalunit_change", args=[unit.pk]))
|
||||
assert response.status_code == 200
|
||||
assert b'name="supervisor_sms_ticket_link_enabled"' in response.content
|
||||
assert "supervisor_sms_ticket_link_enabled" in admin.site._registry[OrganizationalUnit].list_filter
|
||||
Reference in new issue
Block a user