first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
@@ -0,0 +1,111 @@
|
||||
# End-to-end browser tests
|
||||
|
||||
The E2E suite uses Python Playwright, Chromium, `pytest-django`, and Django's
|
||||
transactional `live_server`. It is intentionally separate from fast unit and view tests.
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
tests/e2e/
|
||||
├── config.py # environment-driven browser settings
|
||||
├── conftest.py # browser lifecycle, isolated contexts, failure artifacts
|
||||
├── pages/ # reusable Page Objects and stable UI actions
|
||||
│ ├── base.py
|
||||
│ ├── auth.py
|
||||
│ └── tickets.py
|
||||
├── test_chat_coach.py # user-visible workflow examples
|
||||
└── test_pwa_versioning.py
|
||||
```
|
||||
|
||||
Keep business setup in the test/database layer and browser interactions in Page Objects.
|
||||
Tests should describe user outcomes, not repeat selectors or login steps.
|
||||
|
||||
## Installation
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pip install -r requirements-dev.txt
|
||||
.\.venv\Scripts\python.exe -m playwright install chromium
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```powershell
|
||||
# All browser tests
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e -m e2e -q
|
||||
|
||||
# One scenario
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e\test_chat_coach.py::test_customer_sees_chat_guidance_after_two_rapid_messages -q
|
||||
|
||||
# Only service-worker/PWA scenarios
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e -m pwa -q
|
||||
|
||||
# Visible browser with slowed interactions for debugging
|
||||
$env:E2E_HEADLESS="false"
|
||||
$env:E2E_SLOW_MO="250"
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e -m e2e -s
|
||||
```
|
||||
|
||||
Supported settings are `E2E_HEADLESS`, `E2E_SLOW_MO`, `E2E_ACTION_TIMEOUT_MS`,
|
||||
`E2E_NAVIGATION_TIMEOUT_MS`, `E2E_VIEWPORT_WIDTH`, `E2E_VIEWPORT_HEIGHT`, and
|
||||
`E2E_ARTIFACT_DIR`.
|
||||
|
||||
### QA / staging / live smoke checks
|
||||
|
||||
The destructive workflow suite always uses Django's isolated `live_server`. A separate,
|
||||
read-only smoke layer can target a deployed environment without modifying real tickets:
|
||||
|
||||
```powershell
|
||||
$env:E2E_BASE_URL="https://qa.example.com"
|
||||
$env:E2E_ENVIRONMENT="qa"
|
||||
$env:E2E_CUSTOMER_MOBILE="0912..."
|
||||
$env:E2E_CUSTOMER_PASSWORD="..."
|
||||
$env:E2E_STAFF_USERNAME="qa-staff"
|
||||
$env:E2E_STAFF_PASSWORD="..."
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e\test_external_environment_smoke.py -m external_e2e -q
|
||||
```
|
||||
|
||||
`BASE_URL` is accepted as an alias for `E2E_BASE_URL`. Missing URLs or credentials are
|
||||
reported as skipped tests, never as a successful external run. Use only controlled accounts.
|
||||
|
||||
## Failure diagnostics
|
||||
|
||||
Every test receives a fresh Chromium process and BrowserContext. This keeps Django's synchronous
|
||||
database lifecycle isolated from Playwright's event loop and prevents cross-test browser state.
|
||||
On failure, the fixture automatically saves these files under `test-results/e2e/<test-name>/`:
|
||||
|
||||
- `failure.png`: full-page screenshot at failure time;
|
||||
- `trace.zip`: DOM snapshots, screenshots, network timing and browser actions.
|
||||
|
||||
Open a trace with:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m playwright show-trace test-results\e2e\<test-name>\trace.zip
|
||||
```
|
||||
|
||||
JavaScript page errors also fail the test and produce the same artifacts. Ordinary UI tests
|
||||
block service workers. A PWA test must use `browser_page(service_workers="allow")` and the
|
||||
`pwa` marker.
|
||||
|
||||
## Adding a scenario
|
||||
|
||||
1. Mark the module with `e2e` and `django_db(transaction=True)`.
|
||||
2. Create only the minimum database state required by the scenario.
|
||||
3. Open an isolated page using `with browser_page() as page:`.
|
||||
4. Use an existing Page Object; add reusable selectors/actions there if needed.
|
||||
5. Assert a user-visible outcome with Playwright's auto-waiting `expect` API.
|
||||
6. Avoid `sleep`; wait on URLs, elements, responses, or application state.
|
||||
|
||||
Minimal pattern:
|
||||
|
||||
```python
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
def test_example(live_server, browser_page, customer):
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
tickets = TicketConversationPage(page, live_server.url)
|
||||
tickets.goto("/tickets/")
|
||||
expect(page.get_by_role("heading", name="درخواستهای من")).to_be_visible()
|
||||
```
|
||||
|
||||
The fixture prefers Playwright's bundled Chromium and falls back to installed Google Chrome.
|
||||
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _boolean(name, default):
|
||||
value = os.getenv(name)
|
||||
return default if value is None else value.strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class E2ESettings:
|
||||
base_url: str | None
|
||||
environment: str
|
||||
headless: bool
|
||||
slow_mo: int
|
||||
action_timeout_ms: int
|
||||
navigation_timeout_ms: int
|
||||
viewport: dict
|
||||
artifact_dir: Path
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls):
|
||||
return cls(
|
||||
base_url=(os.getenv("E2E_BASE_URL") or os.getenv("BASE_URL") or "").rstrip("/") or None,
|
||||
environment=os.getenv("E2E_ENVIRONMENT", "local").strip().lower(),
|
||||
headless=_boolean("E2E_HEADLESS", True),
|
||||
slow_mo=int(os.getenv("E2E_SLOW_MO", "0")),
|
||||
action_timeout_ms=int(os.getenv("E2E_ACTION_TIMEOUT_MS", "10000")),
|
||||
navigation_timeout_ms=int(os.getenv("E2E_NAVIGATION_TIMEOUT_MS", "45000")),
|
||||
viewport={
|
||||
"width": int(os.getenv("E2E_VIEWPORT_WIDTH", "1280")),
|
||||
"height": int(os.getenv("E2E_VIEWPORT_HEIGHT", "800")),
|
||||
},
|
||||
artifact_dir=Path(os.getenv("E2E_ARTIFACT_DIR", "test-results/e2e")),
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.config import E2ESettings
|
||||
|
||||
|
||||
playwright_api = pytest.importorskip("playwright.sync_api")
|
||||
|
||||
# On Windows, ``localhost`` can resolve to IPv6 while Django's ephemeral live
|
||||
# server is listening on IPv4. Pin the test-only server to IPv4 so navigation
|
||||
# failures represent the application rather than resolver/bind ambiguity.
|
||||
os.environ.setdefault("DJANGO_LIVE_TEST_SERVER_ADDRESS", "127.0.0.1:0")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def e2e_settings():
|
||||
return E2ESettings.from_environment()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def application_base_url(live_server, e2e_settings):
|
||||
"""Configured deployment URL, falling back to Django's isolated local server."""
|
||||
return e2e_settings.base_url or live_server.url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser_page(request, e2e_settings):
|
||||
"""Return a page factory with per-test isolation and automatic failure artifacts."""
|
||||
|
||||
@contextmanager
|
||||
def open_page(*, service_workers="block"):
|
||||
with playwright_api.sync_playwright() as playwright:
|
||||
launch_options = {"headless": e2e_settings.headless, "slow_mo": e2e_settings.slow_mo}
|
||||
try:
|
||||
browser = playwright.chromium.launch(**launch_options)
|
||||
except playwright_api.Error as bundled_error:
|
||||
try:
|
||||
browser = playwright.chromium.launch(channel="chrome", **launch_options)
|
||||
except playwright_api.Error:
|
||||
raise RuntimeError(
|
||||
"No Chromium browser is available. Run: python -m playwright install chromium"
|
||||
) from bundled_error
|
||||
context = browser.new_context(
|
||||
viewport=e2e_settings.viewport,
|
||||
locale="fa-IR",
|
||||
timezone_id="Asia/Tehran",
|
||||
service_workers=service_workers,
|
||||
)
|
||||
context.set_default_timeout(e2e_settings.action_timeout_ms)
|
||||
context.set_default_navigation_timeout(e2e_settings.navigation_timeout_ms)
|
||||
context.tracing.start(screenshots=True, snapshots=True, sources=True)
|
||||
page = context.new_page()
|
||||
page_errors = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
safe_name = request.node.nodeid.replace("::", "__").replace("/", "_").replace("\\", "_")
|
||||
artifact_dir = e2e_settings.artifact_dir / safe_name
|
||||
failed = False
|
||||
try:
|
||||
yield page
|
||||
if page_errors:
|
||||
raise AssertionError(f"Browser JavaScript errors: {page_errors}")
|
||||
except BaseException:
|
||||
failed = True
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
page.screenshot(path=artifact_dir / "failure.png", full_page=True)
|
||||
raise
|
||||
finally:
|
||||
if failed:
|
||||
context.tracing.stop(path=artifact_dir / "trace.zip")
|
||||
else:
|
||||
context.tracing.stop()
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
return open_page
|
||||
@@ -0,0 +1 @@
|
||||
"""Reusable page objects for browser-level tests."""
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class AuthenticationPage(BasePage):
|
||||
def login_customer(self, customer, password):
|
||||
self.goto(reverse("accounts:password_login"))
|
||||
self.page.locator('input[name="mobile"]').fill(customer.local_mobile)
|
||||
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("tickets:list")))
|
||||
|
||||
def login_staff(self, staff, password):
|
||||
self.goto(reverse("accounts:staff_login"))
|
||||
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:list")))
|
||||
@@ -0,0 +1,13 @@
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
class BasePage:
|
||||
def __init__(self, page, base_url):
|
||||
self.page = page
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
|
||||
def url(self, path):
|
||||
return urljoin(self.base_url, path.lstrip("/"))
|
||||
|
||||
def goto(self, path):
|
||||
return self.page.goto(self.url(path), wait_until="domcontentloaded")
|
||||
@@ -0,0 +1,31 @@
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class CustomerNewTicketPage(BasePage):
|
||||
def __init__(self, page, live_server_url: str):
|
||||
super().__init__(page, live_server_url)
|
||||
self.unit=page.locator("[data-ticket-unit]")
|
||||
self.category=page.locator("[data-ticket-category]")
|
||||
|
||||
def open(self):
|
||||
self.goto("/tickets/new/")
|
||||
expect(self.unit).to_be_visible()
|
||||
|
||||
def select_unit(self, unit_id: int, expected_category: str):
|
||||
with self.page.expect_response(lambda response: "/tickets/new/options/" in response.url and response.ok):
|
||||
self.unit.select_option(str(unit_id))
|
||||
expect(self.category.locator("option", has_text=expected_category)).to_have_count(1)
|
||||
|
||||
def search_and_choose(self, query: str, category_name: str):
|
||||
self.page.locator("[data-ticket-search]").fill(query)
|
||||
result=self.page.get_by_role(
|
||||
"button", name=re.compile(rf"^{re.escape(category_name)}")
|
||||
)
|
||||
expect(result).to_be_visible()
|
||||
result.click()
|
||||
option = self.category.get_by_role("option", name=category_name, exact=True)
|
||||
expect(option).to_have_count(1)
|
||||
expect(self.category).to_have_value(option.get_attribute("value"))
|
||||
@@ -0,0 +1,120 @@
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class TicketConversationPage(BasePage):
|
||||
guidance_text = "لطفاً همه توضیحات و سؤالهای خود را در یک پیام کامل بنویسید و سپس ارسال کنید."
|
||||
|
||||
def send_reply(self, body):
|
||||
self.page.locator("[data-composer-toggle]").click()
|
||||
reply = self.page.locator('form[data-chat-coach] textarea[name="body"]')
|
||||
expect(reply).to_be_visible()
|
||||
reply.fill(body)
|
||||
with self.page.expect_navigation(wait_until="domcontentloaded"):
|
||||
self.page.locator('form[data-chat-coach] [data-primary-submit]').click()
|
||||
assert "reply_result=reply" in self.page.url
|
||||
expect(self.page.locator("[data-conversation]")).to_contain_text(body)
|
||||
|
||||
@property
|
||||
def chat_guidance(self):
|
||||
return self.page.locator(".chat-coach-floating")
|
||||
|
||||
def expect_guidance_visible(self):
|
||||
expect(self.chat_guidance).to_be_visible()
|
||||
expect(self.chat_guidance).to_contain_text(self.guidance_text)
|
||||
|
||||
def expect_guidance_hidden(self):
|
||||
expect(self.chat_guidance).to_be_hidden()
|
||||
|
||||
def dismiss_guidance(self):
|
||||
self.chat_guidance.get_by_role("button", name="بستن راهنما").click()
|
||||
self.expect_guidance_hidden()
|
||||
|
||||
|
||||
class PublicTicketCreatePage(BasePage):
|
||||
def open(self):
|
||||
self.goto("/tickets/new/")
|
||||
expect(self.unit).to_be_visible()
|
||||
|
||||
@property
|
||||
def unit(self):
|
||||
return self.page.locator("[data-ticket-unit]")
|
||||
|
||||
@property
|
||||
def expert_picker(self):
|
||||
return self.page.locator("[data-expert-picker]")
|
||||
|
||||
@property
|
||||
def category(self):
|
||||
return self.page.locator("[data-ticket-category]")
|
||||
|
||||
@property
|
||||
def queue(self):
|
||||
return self.page.locator("[data-general-queue]")
|
||||
|
||||
@property
|
||||
def file_preview(self):
|
||||
return self.page.locator("[data-file-preview]")
|
||||
|
||||
def select_unit(self, unit_id):
|
||||
unit_id = str(unit_id)
|
||||
with self.page.expect_response(
|
||||
lambda response: "/tickets/new/options/" in response.url
|
||||
and f"unit={unit_id}" in response.url
|
||||
and response.status == 200
|
||||
) as response_info:
|
||||
self.unit.select_option(unit_id)
|
||||
payload = response_info.value.json()
|
||||
expect(self.page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", unit_id)
|
||||
expected_ids = {str(item["id"]) for item in payload["experts"]}
|
||||
actual_ids = set(self.expert_picker.locator('input[name="selected_expert"]:not([value=""])').evaluate_all("nodes => nodes.map(node => node.value)"))
|
||||
assert actual_ids == expected_ids
|
||||
return payload
|
||||
|
||||
def select_search_result(self, query, result_text):
|
||||
self.page.locator("[data-ticket-search]").fill(query)
|
||||
result = self.page.locator("[data-ticket-search-results] button").filter(has_text=result_text).first
|
||||
expect(result).to_be_visible()
|
||||
result.click()
|
||||
|
||||
def select_expert(self, display_name):
|
||||
card = self.expert_picker.locator("label.expert-card").filter(has_text=display_name)
|
||||
expect(card).to_have_count(1)
|
||||
radio = card.locator('input[name="selected_expert"]')
|
||||
radio.check()
|
||||
expect(radio).to_be_checked()
|
||||
return radio.get_attribute("value")
|
||||
|
||||
def select_general_queue(self):
|
||||
radio = self.queue.locator('input[name="selected_expert"]')
|
||||
radio.check()
|
||||
expect(radio).to_be_checked()
|
||||
|
||||
def select_category(self, category_id):
|
||||
self.category.select_option(str(category_id))
|
||||
expect(self.category).to_have_value(str(category_id))
|
||||
|
||||
def upload_gallery(self, files, *, total_count=None):
|
||||
self.page.locator("[data-gallery-input]").set_input_files(files)
|
||||
expect(self.file_preview.locator(".file-chip")).to_have_count(total_count or len(files))
|
||||
|
||||
def upload_documents(self, files, *, total_count=None):
|
||||
self.page.locator("[data-document-input]").set_input_files(files)
|
||||
expect(self.file_preview.locator(".file-chip")).to_have_count(total_count or len(files))
|
||||
|
||||
def remove_attachment(self, filename):
|
||||
chip = self.file_preview.locator(".file-chip").filter(has_text=filename)
|
||||
expect(chip).to_have_count(1)
|
||||
chip.get_by_role("button", name="حذف فایل").click()
|
||||
expect(self.file_preview.locator(".file-chip").filter(has_text=filename)).to_have_count(0)
|
||||
|
||||
def submit(self, *, subject, body):
|
||||
self.page.locator('input[name="subject"]').fill(subject)
|
||||
self.page.locator('textarea[name="body"]').fill(body)
|
||||
with self.page.expect_navigation(wait_until="domcontentloaded"):
|
||||
self.page.get_by_role("button", name="ثبت و ارسال درخواست").click()
|
||||
expect(self.page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$"))
|
||||
expect(self.page.locator("h1")).to_have_text(subject)
|
||||
@@ -0,0 +1,78 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.staff.models import StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.tickets import TicketConversationPage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-safe-password-2026"
|
||||
|
||||
|
||||
def test_customer_sees_chat_guidance_after_two_rapid_messages(live_server, browser_page):
|
||||
category = TicketCategory.objects.create(title="فروش E2E", slug="e2e-customer-chat")
|
||||
customer = User.objects.create_user(
|
||||
username="e2e-customer-chat",
|
||||
mobile="09120009001",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
CustomerProfile.objects.create(user=customer, first_name="مشتری", last_name="آزمایشی")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="تست هشدار مشتری", body="سلام")
|
||||
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
conversation = TicketConversationPage(page, live_server.url)
|
||||
conversation.goto(reverse("tickets:detail", args=[ticket.public_id]))
|
||||
conversation.expect_guidance_hidden()
|
||||
conversation.send_reply("قیمت چنده؟")
|
||||
conversation.expect_guidance_visible()
|
||||
conversation.dismiss_guidance()
|
||||
conversation.send_reply("موجود دارید؟")
|
||||
conversation.expect_guidance_visible()
|
||||
conversation.dismiss_guidance()
|
||||
|
||||
|
||||
def test_staff_sees_chat_guidance_after_two_rapid_replies(live_server, browser_page):
|
||||
category = TicketCategory.objects.create(title="پشتیبانی E2E", slug="e2e-staff-chat")
|
||||
customer = User.objects.create_user(
|
||||
username="e2e-staff-customer",
|
||||
mobile="09120009002",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
staff = User.objects.create_user(
|
||||
username="e2e-staff-chat",
|
||||
mobile="09120009003",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.STAFF,
|
||||
)
|
||||
StaffProfile.objects.create(user=staff, display_name="کارشناس E2E")
|
||||
ticket = create_ticket(
|
||||
customer=customer,
|
||||
category=category,
|
||||
subject="تست هشدار کارشناس",
|
||||
selected_expert=staff,
|
||||
body="درخواست مشتری",
|
||||
)
|
||||
ticket.owner = staff
|
||||
ticket.selected_expert = None
|
||||
ticket.save(update_fields=["owner", "selected_expert", "updated_at"])
|
||||
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
|
||||
conversation = TicketConversationPage(page, live_server.url)
|
||||
conversation.goto(reverse("staff_tickets:detail", args=[ticket.public_id]))
|
||||
conversation.send_reply("پاسخ اول کارشناس")
|
||||
conversation.expect_guidance_hidden()
|
||||
conversation.send_reply("پاسخ دوم کارشناس")
|
||||
conversation.expect_guidance_visible()
|
||||
conversation.dismiss_guidance()
|
||||
conversation.send_reply("پاسخ سوم کارشناس")
|
||||
conversation.expect_guidance_visible()
|
||||
conversation.dismiss_guidance()
|
||||
@@ -0,0 +1,73 @@
|
||||
import pytest
|
||||
import re
|
||||
from playwright.sync_api import expect
|
||||
from django.core.management import call_command
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
from apps.tickets.models import TicketCategory
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.customer_new_ticket import CustomerNewTicketPage
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_customer_switches_unit_and_sees_its_categories(live_server, browser_page):
|
||||
customer=User.objects.create_user(username="browser-customer",mobile="09120009991",password="StrongPass123!",account_type=User.AccountType.CUSTOMER)
|
||||
sales=OrganizationalUnit.objects.create(name="فروش مرورگر",slug="e2e-sales",accepts_external_tickets=True)
|
||||
finance=OrganizationalUnit.objects.create(name="حسابداری مرورگر",slug="e2e-finance",accepts_external_tickets=True)
|
||||
TicketCategory.objects.create(unit=sales,title="خرید کالا",slug="e2e-buy")
|
||||
TicketCategory.objects.create(
|
||||
unit=finance,
|
||||
title="ارسال فیش",
|
||||
slug="e2e-receipt",
|
||||
search_keywords="فیش آزمایشی مرورگر اختصاصی",
|
||||
)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page,live_server.url).login_customer(customer,"StrongPass123!")
|
||||
ticket_page=CustomerNewTicketPage(page,live_server.url)
|
||||
ticket_page.open()
|
||||
ticket_page.select_unit(finance.pk,"ارسال فیش")
|
||||
expect(ticket_page.category.locator("option",has_text="خرید کالا")).to_have_count(0)
|
||||
ticket_page.search_and_choose("فیش آزمایشی مرورگر اختصاصی","ارسال فیش")
|
||||
expect(page.locator('[name="province"]')).to_have_js_property("tagName","SELECT")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.parametrize(
|
||||
("query", "category_name", "unit_slug"),
|
||||
[
|
||||
("بار من کجاست و کی میرسه", "پیگیری بارگیری و ارسال", "sales"),
|
||||
("کپی فاکتور رسمی", "فاکتور و صورتحساب", "finance"),
|
||||
("تسویه حساب تامین کننده", "تسویه تأمینکنندگان", "finance"),
|
||||
],
|
||||
ids=("shipping", "invoice", "supplier"),
|
||||
)
|
||||
def test_customer_natural_language_search_selects_helpful_catalog_route(
|
||||
live_server, browser_page, query, category_name, unit_slug
|
||||
):
|
||||
call_command("bootstrap")
|
||||
category = TicketCategory.objects.get(title=category_name)
|
||||
unit_id = OrganizationalUnit.objects.get(slug=unit_slug).pk
|
||||
customer = User.objects.create_user(
|
||||
username=f"catalog-browser-{unit_slug}-{category.pk}",
|
||||
mobile=f"0912{category.pk:07d}",
|
||||
password="StrongPass123!",
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, "StrongPass123!")
|
||||
ticket_page = CustomerNewTicketPage(page, live_server.url)
|
||||
ticket_page.open()
|
||||
page.locator("[data-ticket-search]").fill(query)
|
||||
result = page.get_by_role(
|
||||
"button", name=re.compile(rf"^{re.escape(category_name)}")
|
||||
)
|
||||
expect(result).to_be_visible()
|
||||
expect(result.locator("small")).not_to_have_text("انتخاب این مسیر برای درخواست")
|
||||
result.click()
|
||||
expect(ticket_page.unit).to_have_value(str(unit_id))
|
||||
expect(ticket_page.category).to_have_value(
|
||||
str(category.pk)
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.external_e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
def require_setting(name):
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
pytest.skip(f"{name} is not configured for external E2E")
|
||||
return value
|
||||
|
||||
|
||||
def test_external_customer_login_and_public_routing_are_usable(
|
||||
browser_page, application_base_url, e2e_settings
|
||||
):
|
||||
if not e2e_settings.base_url:
|
||||
pytest.skip("E2E_BASE_URL/BASE_URL is not configured; external environment was not run")
|
||||
mobile = require_setting("E2E_CUSTOMER_MOBILE")
|
||||
password = require_setting("E2E_CUSTOMER_PASSWORD")
|
||||
with browser_page() as page:
|
||||
page.goto(f"{application_base_url}/login/password/", wait_until="domcontentloaded")
|
||||
page.locator('input[name="mobile"]').fill(mobile)
|
||||
page.locator('input[name="password"]').fill(password)
|
||||
page.get_by_role("button", name="ورود به حساب").click()
|
||||
expect(page).to_have_url(f"{application_base_url}/tickets/")
|
||||
page.goto(f"{application_base_url}/tickets/new/", wait_until="domcontentloaded")
|
||||
expect(page.locator("[data-ticket-unit]")).to_be_visible()
|
||||
expect(page.locator("[data-ticket-category]")).to_be_visible()
|
||||
expect(page.locator("[data-expert-picker]")).to_be_visible()
|
||||
|
||||
|
||||
def test_external_staff_login_and_navigation_are_usable(
|
||||
browser_page, application_base_url, e2e_settings
|
||||
):
|
||||
if not e2e_settings.base_url:
|
||||
pytest.skip("E2E_BASE_URL/BASE_URL is not configured; external environment was not run")
|
||||
username = require_setting("E2E_STAFF_USERNAME")
|
||||
password = require_setting("E2E_STAFF_PASSWORD")
|
||||
with browser_page() as page:
|
||||
page.goto(f"{application_base_url}/staff/login/", wait_until="domcontentloaded")
|
||||
page.locator('input[name="username"]').fill(username)
|
||||
page.locator('input[name="password"]').fill(password)
|
||||
page.get_by_role("button", name="ورود به میز کار").click()
|
||||
expect(page).to_have_url(f"{application_base_url}/staff/tickets/")
|
||||
response = page.goto(f"{application_base_url}/staff/tickets/new/", wait_until="domcontentloaded")
|
||||
assert response.status == 200
|
||||
expect(page.locator("[data-staff-unit]")).to_be_visible()
|
||||
@@ -0,0 +1,245 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
from apps.tickets.models import GroupDispatch, TicketCategory
|
||||
from apps.tickets.services import create_ticket, dispatch_group_tickets
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.tickets import PublicTicketCreatePage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-org-permissions-2026"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_data():
|
||||
call_command("bootstrap")
|
||||
users = {name: User.objects.get(username=name) for name in (
|
||||
"kimiya_ghaffari", "maryam_fotovat", "morteza_sokhaeifard", "ms_sabzali",
|
||||
"mehdi_azami", "fereshteh_yavari", "mostafa_sokhaeifard"
|
||||
)}
|
||||
for user in users.values():
|
||||
user.set_password(PASSWORD)
|
||||
user.save(update_fields=["password"])
|
||||
units = {slug: OrganizationalUnit.objects.get(slug=slug) for slug in ("sales", "warehouse-loading", "finance")}
|
||||
return users, units
|
||||
|
||||
|
||||
def make_customer(username, mobile, password=None):
|
||||
user = User.objects.create_user(username=username, mobile=mobile, password=password, account_type=User.AccountType.CUSTOMER)
|
||||
CustomerProfile.objects.create(user=user, first_name=username)
|
||||
return user
|
||||
|
||||
|
||||
def login_staff(page, live_server, user):
|
||||
AuthenticationPage(page, live_server.url).login_staff(user, PASSWORD)
|
||||
|
||||
|
||||
def test_e2e_01_sales_expert_sees_locked_unit_and_sales_categories(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["kimiya_ghaffari"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded")
|
||||
expect(page.locator("[data-staff-unit]")).to_have_text("فروش")
|
||||
expect(page.locator('input[type="hidden"][name="unit"]')).to_have_value(str(units["sales"].pk))
|
||||
categories = page.locator('select[name="category"] option')
|
||||
expect(categories.filter(has_text="عمومی فروش")).to_have_count(1)
|
||||
expect(categories.filter(has_text="مالی و حسابداری")).to_have_count(0)
|
||||
groups = page.locator('select[name="groups"] option')
|
||||
expect(groups.filter(has_text="مخاطبان فروش")).to_have_count(1)
|
||||
expect(groups.filter(has_text="مخاطبان مالی")).to_have_count(0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("username", ["maryam_fotovat", "morteza_sokhaeifard"])
|
||||
def test_e2e_02_sales_leadership_can_login_navigate_and_create_in_its_unit(
|
||||
live_server, browser_page, org_data, username
|
||||
):
|
||||
users, units = org_data
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users[username])
|
||||
expect(page).to_have_url(f"{live_server.url}{reverse('staff_tickets:list')}")
|
||||
response = page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded")
|
||||
assert response.status == 200
|
||||
expect(page.locator("[data-staff-unit]")).to_have_text("فروش")
|
||||
expect(page.locator('input[name="unit"]')).to_have_value(str(units["sales"].pk))
|
||||
|
||||
|
||||
def test_e2e_05_ceo_can_login_and_open_broad_dispatch_workflow(live_server, browser_page, org_data):
|
||||
users, _ = org_data
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["mostafa_sokhaeifard"])
|
||||
expect(page).to_have_url(f"{live_server.url}{reverse('staff_tickets:list')}")
|
||||
response = page.goto(
|
||||
f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded"
|
||||
)
|
||||
assert response.status == 200
|
||||
expect(page.locator("body")).to_contain_text("ارسال گروهی")
|
||||
|
||||
|
||||
def test_e2e_02_contact_group_picker_excludes_other_units(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
sales_group = ContactGroup.objects.get(unit=units["sales"], name__startswith="مخاطبان")
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["kimiya_ghaffari"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:contact_create')}", wait_until="domcontentloaded")
|
||||
options = page.locator('select[name="groups"] option')
|
||||
expect(options.filter(has_text="مخاطبان فروش")).to_have_count(1)
|
||||
expect(options.filter(has_text="بارگیری انبار")).to_have_count(0)
|
||||
expect(options.filter(has_text="مالی / حسابداری")).to_have_count(0)
|
||||
page.locator('input[name="mobile"]').fill("09120008119")
|
||||
page.locator('input[name="first_name"]').fill("مخاطب")
|
||||
page.locator('input[name="last_name"]').fill("فروش")
|
||||
page.locator('select[name="stakeholder_type"]').select_option(CustomerProfile.StakeholderType.CUSTOMER)
|
||||
page.locator('select[name="groups"]').select_option(str(sales_group.pk))
|
||||
with page.expect_navigation(wait_until="domcontentloaded"):
|
||||
page.get_by_role("button", name="ذخیره مخاطب").click()
|
||||
expect(page.locator("body")).to_contain_text("مخاطب فروش")
|
||||
contact = User.objects.get(mobile="+989120008119")
|
||||
assert list(contact.customer_profile.groups.all()) == [sales_group]
|
||||
|
||||
|
||||
def test_e2e_02_staff_ticket_creation_finishes_with_sales_contact_group(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
sales_group = ContactGroup.objects.get(unit=units["sales"], name__startswith="مخاطبان")
|
||||
category = TicketCategory.objects.get(slug="sales-general")
|
||||
subject = "گردش کامل ایجاد تیکت Staff E2E"
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["kimiya_ghaffari"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded")
|
||||
page.locator('input[name="customer_mobile"]').fill("09120008118")
|
||||
page.locator('input[name="first_name"]').fill("مشتری")
|
||||
page.locator('input[name="last_name"]').fill("فروش")
|
||||
page.locator('select[name="groups"]').select_option(str(sales_group.pk))
|
||||
page.locator('select[name="category"]').select_option(str(category.pk))
|
||||
page.locator('input[name="subject"]').fill(subject)
|
||||
page.locator('textarea[name="body"]').fill("شرح کامل ثبت توسط کارشناس فروش")
|
||||
with page.expect_navigation(wait_until="domcontentloaded"):
|
||||
page.get_by_role("button", name="ثبت تیکت و ارسال لینک امن").click()
|
||||
expect(page.locator("h1")).to_have_text(subject)
|
||||
ticket = TicketCategory.objects.get(pk=category.pk).tickets.get(subject=subject)
|
||||
assert ticket.owning_unit == units["sales"]
|
||||
assert ticket.owner == users["kimiya_ghaffari"]
|
||||
assert sales_group in ticket.customer.customer_profile.groups.all()
|
||||
|
||||
|
||||
def test_e2e_03_warehouse_staff_cannot_see_sales_tickets(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
buyer = make_customer("e2e-scope-buyer", "09120008101")
|
||||
create_ticket(customer=buyer, unit=units["sales"], category=TicketCategory.objects.get(slug="sales-general"), subject="تیکت محرمانه فروش E2E", body="شرح")
|
||||
create_ticket(customer=buyer, unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت مجاز بارگیری E2E", body="شرح")
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["ms_sabzali"])
|
||||
content = page.locator("body")
|
||||
expect(content).to_contain_text("تیکت مجاز بارگیری E2E")
|
||||
expect(content).not_to_contain_text("تیکت محرمانه فروش E2E")
|
||||
|
||||
|
||||
def test_e2e_04_accounting_sees_only_finance_scope(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
buyer = make_customer("e2e-finance-buyer", "09120008102")
|
||||
create_ticket(customer=buyer, unit=units["finance"], category=TicketCategory.objects.get(slug="finance-general"), subject="تیکت مالی مجاز E2E", body="شرح")
|
||||
create_ticket(customer=buyer, unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت انبار نامجاز E2E", body="شرح")
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["fereshteh_yavari"])
|
||||
expect(page.locator("body")).to_contain_text("تیکت مالی مجاز E2E")
|
||||
expect(page.locator("body")).not_to_contain_text("تیکت انبار نامجاز E2E")
|
||||
|
||||
|
||||
def prepare_operation(users, units, operation_type, suffix):
|
||||
warehouse = units["warehouse-loading"]
|
||||
contact = make_customer(f"e2e-operation-contact-{suffix}", f"091200082{suffix}")
|
||||
group = ContactGroup.objects.create(unit=warehouse, name=f"گروه عملیات E2E {suffix}")
|
||||
contact.customer_profile.groups.add(group)
|
||||
category = TicketCategory.objects.get(slug="warehouse-loading-general")
|
||||
other = users["mehdi_azami"]
|
||||
codename = "create_bulk_dispatch" if operation_type == GroupDispatch.OperationType.BULK else "create_inquiry"
|
||||
other.unit_memberships.get(unit=warehouse).permissions.add(Permission.objects.get(content_type__app_label="tickets", codename=codename))
|
||||
foreign, _ = dispatch_group_tickets(user=other, unit=warehouse, contact_group=group, category=category, operation_type=operation_type, subject=f"عملیات متعلق به مهدی {suffix}", body="متن عملیات")
|
||||
return group, category, foreign
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation_type,suffix", [(GroupDispatch.OperationType.BULK, "03"), (GroupDispatch.OperationType.INQUIRY, "04")])
|
||||
def test_e2e_05_06_limited_operation_owner_and_ceo_view_all(live_server, browser_page, org_data, operation_type, suffix):
|
||||
users, units = org_data
|
||||
group, category, foreign = prepare_operation(users, units, operation_type, suffix)
|
||||
own_subject = "ارسال گروهی سبزعلی E2E" if operation_type == GroupDispatch.OperationType.BULK else "استعلام سبزعلی E2E"
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["ms_sabzali"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded")
|
||||
expect(page.locator("body")).not_to_contain_text(foreign.subject)
|
||||
composer = page.locator(".dispatch-composer")
|
||||
composer.locator('select[name="operation_type"]').select_option(operation_type)
|
||||
composer.locator('select[name="unit"]').select_option(str(units["warehouse-loading"].pk))
|
||||
composer.locator('select[name="contact_group"]').select_option(str(group.pk))
|
||||
composer.locator('select[name="category"]').select_option(str(category.pk))
|
||||
composer.locator('input[name="subject"]').fill(own_subject)
|
||||
composer.locator('textarea[name="body"]').fill("متن کامل عملیات E2E")
|
||||
page.get_by_role("button", name="ایجاد تیکتها").click()
|
||||
expect(page.locator("body")).to_contain_text(own_subject)
|
||||
page.context.clear_cookies()
|
||||
login_staff(page, live_server, users["mostafa_sokhaeifard"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded")
|
||||
expect(page.locator("body")).to_contain_text(own_subject)
|
||||
expect(page.locator("body")).to_contain_text(foreign.subject)
|
||||
|
||||
|
||||
def test_e2e_07_manual_unit_change_refreshes_staff_and_queue(live_server, browser_page, org_data):
|
||||
_, units = org_data
|
||||
buyer = make_customer("e2e-dynamic-manual", "09120008105", PASSWORD)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(buyer, PASSWORD)
|
||||
page.goto(f"{live_server.url}{reverse('tickets:new')}", wait_until="domcontentloaded")
|
||||
ticket_page = PublicTicketCreatePage(page, live_server.url)
|
||||
sales_payload = ticket_page.select_unit(units["sales"].pk)
|
||||
assert {item["name"] for item in sales_payload["experts"]}.issuperset({"کیمیا غفاری", "زهرا جمالی"})
|
||||
expect(page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", str(units["sales"].pk))
|
||||
expect(page.locator("[data-general-queue]")).to_contain_text("صف عمومی فروش")
|
||||
expect(page.locator("[data-expert-picker]")).to_contain_text("کیمیا غفاری")
|
||||
finance_payload = ticket_page.select_unit(units["finance"].pk)
|
||||
assert {item["name"] for item in finance_payload["experts"]}.issuperset({"خانم فرشته یاوری"})
|
||||
expect(page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", str(units["finance"].pk))
|
||||
expect(page.locator("[data-general-queue]")).to_contain_text("صف عمومی مالی / حسابداری")
|
||||
expect(page.locator("[data-expert-picker]")).to_contain_text("خانم فرشته یاوری")
|
||||
expect(page.locator("[data-expert-picker]")).not_to_contain_text("کیمیا غفاری")
|
||||
|
||||
|
||||
def test_e2e_08_search_selection_uses_same_unit_staff_queue_pipeline(live_server, browser_page, org_data):
|
||||
_, units = org_data
|
||||
buyer = make_customer("e2e-dynamic-search", "09120008106", PASSWORD)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(buyer, PASSWORD)
|
||||
page.goto(f"{live_server.url}{reverse('tickets:new')}", wait_until="domcontentloaded")
|
||||
ticket_page = PublicTicketCreatePage(page, live_server.url)
|
||||
ticket_page.select_search_result("عمومی فروش", "عمومی فروش")
|
||||
expect(page.locator('[data-ticket-unit]')).to_have_value(str(units["sales"].pk))
|
||||
expect(page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", str(units["sales"].pk))
|
||||
expect(page.locator("[data-expert-picker]")).to_contain_text("کیمیا غفاری")
|
||||
expect(page.locator("[data-expert-picker]")).not_to_contain_text("خانم فرشته یاوری")
|
||||
|
||||
|
||||
def test_unit_change_renders_experts_when_a_legacy_cached_payload_has_no_queue(live_server, browser_page, org_data):
|
||||
_, units = org_data
|
||||
buyer = make_customer("e2e-legacy-options-payload", "09120008107", PASSWORD)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(buyer, PASSWORD)
|
||||
|
||||
def remove_queue_from_response(route):
|
||||
response = route.fetch()
|
||||
payload = response.json()
|
||||
payload.pop("queue", None)
|
||||
route.fulfill(response=response, json=payload)
|
||||
|
||||
page.route("**/tickets/new/options/**", remove_queue_from_response)
|
||||
page.goto(f"{live_server.url}{reverse('tickets:new')}", wait_until="domcontentloaded")
|
||||
ticket_page = PublicTicketCreatePage(page, live_server.url)
|
||||
payload = ticket_page.select_unit(units["warehouse-loading"].pk)
|
||||
assert payload.get("queue") is None
|
||||
expect(ticket_page.expert_picker).to_contain_text("خانم سبزعلی")
|
||||
expect(ticket_page.expert_picker).to_contain_text("آقای مهدی عظمی")
|
||||
expect(page.get_by_role("alert")).to_have_count(0)
|
||||
@@ -0,0 +1,290 @@
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.tickets import PublicTicketCreatePage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-public-workflow-2026"
|
||||
|
||||
|
||||
def image_file(name, image_format, color):
|
||||
pillow = pytest.importorskip("PIL.Image")
|
||||
stream = io.BytesIO()
|
||||
pillow.new("RGB", (8, 8), color).save(stream, format=image_format)
|
||||
return {
|
||||
"name": name,
|
||||
"mimeType": "image/jpeg" if image_format == "JPEG" else "image/png",
|
||||
"buffer": stream.getvalue(),
|
||||
}
|
||||
|
||||
|
||||
def pdf_file(name, marker="one"):
|
||||
return {
|
||||
"name": name,
|
||||
"mimeType": "application/pdf",
|
||||
"buffer": (
|
||||
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
"2 0 obj<</Type/Pages/Count 0/Kids[]>>endobj\n"
|
||||
f"% {marker}\n%%EOF\n"
|
||||
).encode(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_workflow(settings, tmp_path):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
call_command("bootstrap")
|
||||
customer = User.objects.create_user(
|
||||
username="e2e-public-workflow",
|
||||
mobile="09120008301",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
CustomerProfile.objects.create(user=customer, first_name="مشتری", last_name="مرورگر")
|
||||
units = {
|
||||
slug: OrganizationalUnit.objects.get(slug=slug)
|
||||
for slug in ("sales", "warehouse-loading", "finance")
|
||||
}
|
||||
categories = {
|
||||
"sales_general": TicketCategory.objects.get(slug="sales-general"),
|
||||
"sales_secondary": TicketCategory.objects.create(
|
||||
unit=units["sales"],
|
||||
title="شرایط فروش E2E",
|
||||
slug="sales-secondary-e2e",
|
||||
is_active=True,
|
||||
show_in_customer_form=True,
|
||||
),
|
||||
"warehouse": TicketCategory.objects.get(slug="warehouse-loading-general"),
|
||||
"finance": TicketCategory.objects.get(slug="finance-general"),
|
||||
}
|
||||
return customer, units, categories
|
||||
|
||||
|
||||
def open_form(page, live_server, customer):
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
form = PublicTicketCreatePage(page, live_server.url)
|
||||
form.open()
|
||||
return form
|
||||
|
||||
|
||||
def assert_ticket_record(subject, *, unit, category, expert_name, attachment_names=()):
|
||||
ticket = Ticket.objects.select_related("selected_expert__staff_profile").get(subject=subject)
|
||||
assert ticket.owning_unit == unit
|
||||
assert ticket.assigned_unit == unit
|
||||
assert ticket.category == category
|
||||
actual_expert = ticket.selected_expert.staff_profile.display_name if ticket.selected_expert_id else "صف عمومی"
|
||||
assert actual_expert == expert_name
|
||||
attachments = ticket.ticket_messages.get().attachments.order_by("original_name")
|
||||
assert set(attachments.values_list("original_name", flat=True)) == set(attachment_names)
|
||||
|
||||
|
||||
def assert_final_ticket_ui(page, *, category, expert_name, attachment_names=()):
|
||||
expect(page.locator("#ticket-info")).to_contain_text(category.title)
|
||||
expect(page.locator("#ticket-info")).to_contain_text(expert_name)
|
||||
for name in attachment_names:
|
||||
expect(page.locator("[data-conversation]")).to_contain_text(name)
|
||||
|
||||
|
||||
def test_e2e_07_sales_general_queue_creates_verifiable_ticket(live_server, browser_page, public_workflow):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "ثبت واقعی در صف عمومی فروش E2E"
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.select_general_queue()
|
||||
form.submit(subject=subject, body="شرح کامل تیکت بدون پیوست")
|
||||
assert_final_ticket_ui(page, category=categories["sales_general"], expert_name="صف عمومی")
|
||||
assert_ticket_record(
|
||||
subject, unit=units["sales"], category=categories["sales_general"], expert_name="صف عمومی"
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_08_repeated_staff_changes_store_only_last_choice(live_server, browser_page, public_workflow):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "آخرین کارشناس انتخابی E2E"
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
for name in ("کیمیا غفاری", "زهرا جمالی", "مریم فتوت", "زهرا جمالی"):
|
||||
form.select_expert(name)
|
||||
form.submit(subject=subject, body="تغییر چندباره کارشناس")
|
||||
assert_final_ticket_ui(page, category=categories["sales_general"], expert_name="زهرا جمالی")
|
||||
assert_ticket_record(
|
||||
subject, unit=units["sales"], category=categories["sales_general"], expert_name="زهرا جمالی"
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_subject_stays_empty_for_manual_and_search_routing_until_user_types(
|
||||
live_server, browser_page, public_workflow
|
||||
):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "موضوع کاملاً دستی E2E"
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
subject_input = page.locator("[data-ticket-subject]")
|
||||
unit_box = form.unit.bounding_box()
|
||||
category_box = form.category.bounding_box()
|
||||
assert unit_box and category_box
|
||||
assert abs(unit_box["y"] - category_box["y"]) < 2
|
||||
assert unit_box["x"] + unit_box["width"] <= category_box["x"] or category_box["x"] + category_box["width"] <= unit_box["x"]
|
||||
expect(subject_input).to_have_value("")
|
||||
expect(subject_input).to_have_attribute("required", "")
|
||||
form.select_unit(units["finance"].pk)
|
||||
form.select_category(categories["finance"].pk)
|
||||
expect(subject_input).to_have_value("")
|
||||
form.select_search_result("عمومی فروش", "عمومی فروش")
|
||||
expect(form.unit).to_have_value(str(units["sales"].pk))
|
||||
expect(subject_input).to_have_value("")
|
||||
subject_input.fill(subject)
|
||||
form.select_unit(units["finance"].pk)
|
||||
expect(subject_input).to_have_value(subject)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.select_general_queue()
|
||||
form.submit(subject=subject, body="موضوع فقط توسط کاربر وارد شده است")
|
||||
assert_final_ticket_ui(page, category=categories["sales_general"], expert_name="صف عمومی")
|
||||
assert_ticket_record(
|
||||
subject, unit=units["sales"], category=categories["sales_general"], expert_name="صف عمومی"
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_experts_follow_configured_numeric_order(live_server, browser_page, public_workflow):
|
||||
customer, units, _ = public_workflow
|
||||
configured = {
|
||||
"زهرا جمالی": 10,
|
||||
"مریم فتوت": 20,
|
||||
"کیمیا غفاری": 30,
|
||||
"مرتضی سخائی فرد": 40,
|
||||
}
|
||||
for name, order in configured.items():
|
||||
StaffProfile.objects.filter(primary_unit=units["sales"], display_name=name).update(sort_order=order)
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
names = form.expert_picker.locator(
|
||||
"label.expert-card:not([data-general-queue]) strong"
|
||||
).all_text_contents()
|
||||
assert names == ["زهرا جمالی", "مریم فتوت", "کیمیا غفاری", "مرتضی سخائی فرد"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"suffix,images,documents,expected",
|
||||
[
|
||||
("jpg", [image_file("photo.jpg", "JPEG", "red")], [], ["photo.jpg"]),
|
||||
("pdf", [], [pdf_file("document.pdf")], ["document.pdf"]),
|
||||
(
|
||||
"mixed",
|
||||
[image_file("first.jpg", "JPEG", "blue"), image_file("second.png", "PNG", "green")],
|
||||
[pdf_file("combined.pdf")],
|
||||
["first.jpg", "second.png", "combined.pdf"],
|
||||
),
|
||||
(
|
||||
"multi-file",
|
||||
[],
|
||||
[pdf_file("first.pdf", "first"), pdf_file("second.pdf", "second")],
|
||||
["first.pdf", "second.pdf"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_e2e_09_10_11_attachment_variants_are_saved_and_rendered(
|
||||
live_server, browser_page, public_workflow, suffix, images, documents, expected
|
||||
):
|
||||
customer, units, categories = public_workflow
|
||||
subject = f"پیوست واقعی {suffix} E2E"
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.select_general_queue()
|
||||
if images:
|
||||
form.upload_gallery(images)
|
||||
if documents:
|
||||
form.upload_documents(documents, total_count=len(images) + len(documents))
|
||||
form.submit(subject=subject, body="بررسی ذخیره و نمایش تمام پیوستها")
|
||||
assert_final_ticket_ui(
|
||||
page, category=categories["sales_general"], expert_name="صف عمومی", attachment_names=expected
|
||||
)
|
||||
assert_ticket_record(
|
||||
subject,
|
||||
unit=units["sales"],
|
||||
category=categories["sales_general"],
|
||||
expert_name="صف عمومی",
|
||||
attachment_names=expected,
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_11_removed_attachment_is_not_submitted(live_server, browser_page, public_workflow):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "حذف پیوست پیش از ثبت E2E"
|
||||
keep = image_file("keep.png", "PNG", "yellow")
|
||||
remove = pdf_file("remove.pdf")
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.upload_gallery([keep])
|
||||
form.upload_documents([remove], total_count=2)
|
||||
form.remove_attachment("remove.pdf")
|
||||
form.submit(subject=subject, body="فقط تصویر باید ذخیره شود")
|
||||
assert_final_ticket_ui(
|
||||
page, category=categories["sales_general"], expert_name="صف عمومی", attachment_names=["keep.png"]
|
||||
)
|
||||
assert_ticket_record(
|
||||
subject,
|
||||
unit=units["sales"],
|
||||
category=categories["sales_general"],
|
||||
expert_name="صف عمومی",
|
||||
attachment_names=["keep.png"],
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_12_sequential_unit_staff_category_and_attachment_state(live_server, browser_page, public_workflow):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "مدیریت وضعیت متوالی فرم E2E"
|
||||
image_one = image_file("state-one.jpg", "JPEG", "purple")
|
||||
image_two = image_file("state-two.png", "PNG", "orange")
|
||||
removed_pdf = pdf_file("state-remove.pdf")
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_expert("کیمیا غفاری")
|
||||
form.select_category(categories["sales_secondary"].pk)
|
||||
form.upload_gallery([image_one])
|
||||
form.upload_documents([removed_pdf], total_count=2)
|
||||
form.select_unit(units["finance"].pk)
|
||||
expect(form.expert_picker).not_to_contain_text("کیمیا غفاری")
|
||||
expect(form.queue).to_contain_text("صف عمومی مالی / حسابداری")
|
||||
form.select_category(categories["finance"].pk)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.select_expert("کیمیا غفاری")
|
||||
form.select_expert("زهرا جمالی")
|
||||
form.select_expert("کیمیا غفاری")
|
||||
form.remove_attachment("state-remove.pdf")
|
||||
form.upload_gallery([image_two], total_count=2)
|
||||
expect(form.file_preview.locator(".file-chip")).to_have_count(2)
|
||||
form.submit(subject=subject, body="فرم پس از تغییرات متوالی سالم ماند")
|
||||
assert_final_ticket_ui(
|
||||
page,
|
||||
category=categories["sales_general"],
|
||||
expert_name="کیمیا غفاری",
|
||||
attachment_names=["state-one.jpg", "state-two.png"],
|
||||
)
|
||||
assert_ticket_record(
|
||||
subject,
|
||||
unit=units["sales"],
|
||||
category=categories["sales_general"],
|
||||
expert_name="کیمیا غفاری",
|
||||
attachment_names=["state-one.jpg", "state-two.png"],
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.accounts.models import User
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.pwa, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-safe-password-2026"
|
||||
|
||||
|
||||
def test_pwa_registers_versioned_worker_and_matching_cache(live_server, browser_page):
|
||||
customer = User.objects.create_user(
|
||||
username="e2e-pwa-version",
|
||||
mobile="09120009010",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
|
||||
with browser_page(service_workers="allow") as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
|
||||
state = page.evaluate(
|
||||
"""async () => {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const worker = registration.active || registration.waiting || registration.installing;
|
||||
return {
|
||||
version: document.documentElement.dataset.staticVersion,
|
||||
scriptURL: worker && worker.scriptURL,
|
||||
cacheNames: await caches.keys(),
|
||||
};
|
||||
}"""
|
||||
)
|
||||
|
||||
assert state["version"]
|
||||
assert f"/service-worker.js?v={quote(state['version'])}" in state["scriptURL"]
|
||||
assert f"sales-ticket-{state['version']}" in state["cacheNames"]
|
||||
|
||||
worker_response = page.request.get(state["scriptURL"])
|
||||
assert worker_response.ok
|
||||
assert worker_response.headers["cache-control"] == "no-cache, no-store, must-revalidate"
|
||||
@@ -0,0 +1,88 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.accounts.models import TicketMagicLink, User
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.core.models import PageTitle, SiteBranding
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-safe-password-2026"
|
||||
|
||||
|
||||
def test_staff_operations_pages_are_authorized_and_visually_styled(live_server, browser_page):
|
||||
branding = SiteBranding.load()
|
||||
branding.staff_brand_title = "مرکز عملیات E2E"
|
||||
branding.staff_brand_subtitle = "ارتباطات یکپارچه E2E"
|
||||
branding.login_intro_eyebrow = "معرفی ورود E2E"
|
||||
branding.developer_credit_text = "توسعه E2E | 09120000000"
|
||||
branding.staff_sidebar_credit_text = "امضای مستقل sidebar E2E"
|
||||
branding.save()
|
||||
PageTitle.objects.update_or_create(route_name="staff_tickets:dispatch_create", defaults={"title": "ردیابی E2E"})
|
||||
SMSTemplate.objects.update_or_create(unit=None, event_type=SMSTemplate.Event.STAFF_REPLY, defaults={"title": "الگوی E2E", "body": "{ticket_number}: {magic_link}"})
|
||||
unit = OrganizationalUnit.objects.create(name="عملیات E2E", slug="operations-e2e")
|
||||
permissions = {
|
||||
"customers": {"view_customerprofile", "add_customerprofile", "change_customerprofile", "view_contactgroup", "add_contactgroup", "change_contactgroup"},
|
||||
"tickets": {"view_ticket", "dispatch_group_tickets"},
|
||||
"notifications": {"view_smstemplate", "add_smstemplate", "change_smstemplate"},
|
||||
"accounts": {"view_ticketmagiclink"},
|
||||
}
|
||||
query = None
|
||||
from django.db.models import Q
|
||||
for app_label, codenames in permissions.items():
|
||||
part = Q(content_type__app_label=app_label, codename__in=codenames)
|
||||
query = part if query is None else query | part
|
||||
unit.permissions.add(*Permission.objects.filter(query))
|
||||
staff = User.objects.create_user(username="e2e-operations", password=PASSWORD, account_type=User.AccountType.STAFF)
|
||||
StaffProfile.objects.create(user=staff, display_name="کارشناس عملیات", primary_unit=unit)
|
||||
customer = User.objects.create_user(username="e2e-carrier", mobile="09120009121", account_type=User.AccountType.CUSTOMER)
|
||||
profile = CustomerProfile.objects.create(user=customer, first_name="باربری", stakeholder_type=CustomerProfile.StakeholderType.CARRIER)
|
||||
group = ContactGroup.objects.create(unit=unit, name="باربریها")
|
||||
profile.groups.add(group)
|
||||
category = TicketCategory.objects.create(title="استعلام E2E", slug="operations-e2e")
|
||||
ticket = create_ticket(customer=customer, created_by=staff, unit=unit, category=category, subject="استعلام آزمایشی", body="قیمت را اعلام کنید")
|
||||
TicketMagicLink.issue_with_record(ticket, created_by=staff)
|
||||
|
||||
with browser_page() as page:
|
||||
page.goto(f"{live_server.url}{reverse('accounts:login')}", wait_until="domcontentloaded")
|
||||
expect(page.locator(".auth-brand strong")).to_have_count(0)
|
||||
expect(page.locator(".auth-intro .eyebrow")).to_have_text("معرفی ورود E2E")
|
||||
expect(page.locator(".auth-footer")).to_contain_text("توسعه E2E")
|
||||
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
|
||||
for route, heading in (
|
||||
("staff_tickets:contacts", "مخاطبان و مشتریان"),
|
||||
("staff_tickets:groups", "گروههای مخاطبان"),
|
||||
("staff_tickets:contact_create", "ثبت مخاطب بیرونی"),
|
||||
("staff_tickets:sms_templates", "الگوهای پیامک واحدها"),
|
||||
("staff_tickets:dispatch_create", "ارسال گروهی ایزوله"),
|
||||
("staff_tickets:magic_audit", "ممیزی لینکهای جادویی"),
|
||||
):
|
||||
page.goto(f"{live_server.url}{reverse(route)}", wait_until="domcontentloaded")
|
||||
expect(page.get_by_role("heading", name=heading, level=1)).to_be_visible()
|
||||
expect(page.locator('link[href*="operations.css"]')).to_have_count(1)
|
||||
assert page.evaluate("getComputedStyle(document.querySelector('.operations-page')).display") == "grid"
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded")
|
||||
expect(page).to_have_title("ردیابی E2E")
|
||||
expect(page.locator(".staff-brand-copy b")).to_have_text("مرکز عملیات E2E")
|
||||
expect(page.locator(".staff-developer-credit")).to_contain_text("امضای مستقل sidebar E2E")
|
||||
expect(page.get_by_role("searchbox", name="جستجو")).to_be_visible()
|
||||
expect(page.locator('link[href*="dispatches.css"]')).to_have_count(1)
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:sms_templates')}", wait_until="domcontentloaded")
|
||||
expect(page.get_by_text("الگوی عمومی سیستم").first).to_be_visible()
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:contact_create')}", wait_until="domcontentloaded")
|
||||
province = page.locator('select[name="province"]')
|
||||
city = page.locator('select[name="city"]')
|
||||
expect(province.locator("option")).not_to_have_count(1)
|
||||
province.select_option(label="اصفهان")
|
||||
expect(city.locator("option", has_text="کاشان")).to_have_count(1)
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
|
||||
const listeners={};
|
||||
const input={value:'',addEventListener:(name,handler)=>{listeners[`input:${name}`]=handler},focus:()=>{}};
|
||||
const closeButton={addEventListener:(name,handler)=>{listeners[`close:${name}`]=handler}};
|
||||
const hint={hidden:true,setAttribute:()=>{},querySelector:()=>closeButton};
|
||||
const form={dataset:{chatCoachKey:'test-ticket'},action:'/tickets/example/reply/',querySelector:()=>input};
|
||||
const renderedMessages=[];
|
||||
let appendedHint=null;
|
||||
|
||||
globalThis.document={
|
||||
body:{append:node=>{appendedHint=node}},
|
||||
querySelectorAll:selector=>selector==='form[data-chat-coach]'?[form]:renderedMessages,
|
||||
querySelector:selector=>selector==='[data-conversation]'?{}:null,
|
||||
createElement:()=>hint,
|
||||
};
|
||||
globalThis.location={origin:'http://testserver',pathname:'/tickets/example/'};
|
||||
globalThis.MutationObserver=class{constructor(handler){listeners.mutation=handler}observe(){}};
|
||||
globalThis.setTimeout=handler=>{listeners.autoDismiss=handler;return 1};
|
||||
globalThis.clearTimeout=()=>{delete listeners.autoDismiss};
|
||||
|
||||
let now=1_000_000;
|
||||
Date.now=()=>now;
|
||||
await import('../../static/js/chat-coach.js');
|
||||
|
||||
test('mounts guidance directly on body so collapsed forms cannot hide it',()=>{
|
||||
assert.equal(appendedHint,hint);
|
||||
assert.match(hint.className,/chat-coach-floating/);
|
||||
});
|
||||
|
||||
test('shows guidance when the last two messages are at most two minutes apart',()=>{
|
||||
renderedMessages.push(
|
||||
{dataset:{chatCoachAt:String((now-40_000)/1000)}},
|
||||
{dataset:{chatCoachAt:String((now-10_000)/1000)}},
|
||||
);
|
||||
listeners.mutation();
|
||||
assert.equal(hint.hidden,false);
|
||||
});
|
||||
|
||||
test('hides guidance when the last two messages are more than two minutes apart',()=>{
|
||||
renderedMessages.splice(0,renderedMessages.length,
|
||||
{dataset:{chatCoachAt:String((now-200_000)/1000)}},
|
||||
{dataset:{chatCoachAt:String((now-10_000)/1000)}},
|
||||
);
|
||||
listeners.mutation();
|
||||
assert.equal(hint.hidden,true);
|
||||
});
|
||||
|
||||
test('guidance is configured to disappear automatically after three seconds',()=>{
|
||||
const script=readFileSync('static/js/chat-coach.js','utf8');
|
||||
assert.match(script,/autoDismissMs=3000/);
|
||||
assert.match(script,/setTimeout\(\(\)=>dismiss\(\),autoDismissMs\)/);
|
||||
assert.equal(typeof listeners.autoDismiss,'function');
|
||||
});
|
||||
|
||||
test('large close button dismisses the guidance for the current page',()=>{
|
||||
renderedMessages.splice(0,renderedMessages.length,
|
||||
{dataset:{chatCoachAt:String((now-30_000)/1000)}},
|
||||
{dataset:{chatCoachAt:String((now-5_000)/1000)}},
|
||||
);
|
||||
listeners.mutation();
|
||||
assert.equal(hint.hidden,false);
|
||||
listeners['close:click']();
|
||||
assert.equal(hint.hidden,true);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.core.models import PageTitle, SiteBranding
|
||||
from apps.customers.models import ContactGroup
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
from apps.tickets.models import GroupDispatch, TicketCategory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_login_uses_database_title_and_brand_without_login_card_copy(client):
|
||||
branding = SiteBranding.load()
|
||||
branding.header_title = "سامانه ارتباطات آزمایشی"
|
||||
branding.header_subtitle = "پیگیری یکپارچه"
|
||||
branding.login_intro_icon = "●"
|
||||
branding.login_intro_eyebrow = "ارتباط مستقیم آزمایشی"
|
||||
branding.login_intro_heading = "عنوان سطر اول\nعنوان سطر دوم"
|
||||
branding.login_intro_body = "توضیح قابل مدیریت از دیتابیس"
|
||||
branding.developer_credit_text = "توسعه: تیم آزمایشی | ۰۹۱۲۰۰۰۰۰۰۰"
|
||||
branding.save()
|
||||
PageTitle.objects.update_or_create(route_name="accounts:login", defaults={"title": "ورود سفارشی", "is_active": True})
|
||||
|
||||
response = client.get(reverse("accounts:login"))
|
||||
content = response.content.decode()
|
||||
assert "<title>ورود سفارشی</title>" in content
|
||||
assert "سامانه ارتباطات آزمایشی" in content # page header is database-backed
|
||||
assert '<div class="auth-brand"' in content
|
||||
assert '<div><strong>سامانه ارتباطات آزمایشی</strong>' not in content
|
||||
assert "ارتباط مستقیم آزمایشی" in content
|
||||
assert "عنوان سطر اول<br>عنوان سطر دوم" in content
|
||||
assert "توضیح قابل مدیریت از دیتابیس" in content
|
||||
assert "توسعه: تیم آزمایشی | ۰۹۱۲۰۰۰۰۰۰۰" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_developer_credit_html_requires_explicit_trusted_html_switch(client):
|
||||
branding = SiteBranding.load()
|
||||
branding.developer_credit_html = '<a href="https://example.com" style="color:red"><span>★</span> توسعه نمونه</a>'
|
||||
branding.use_developer_credit_html = False
|
||||
branding.save()
|
||||
escaped = client.get(reverse("accounts:login")).content.decode()
|
||||
assert "https://example.com" not in escaped
|
||||
|
||||
branding.use_developer_credit_html = True
|
||||
branding.save()
|
||||
rendered = client.get(reverse("accounts:login")).content.decode()
|
||||
assert '<a href="https://example.com" style="color:red"><span>★</span> توسعه نمونه</a>' in rendered
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_sidebar_brand_is_database_backed(client):
|
||||
user = User.objects.create_superuser(username="brand-admin", password="safe-password", account_type=User.AccountType.STAFF)
|
||||
branding = SiteBranding.load()
|
||||
branding.staff_brand_title = "مرکز ارتباطات سازمانی"
|
||||
branding.staff_brand_subtitle = "میز خدمت یکپارچه"
|
||||
branding.staff_panel_label = "فضای کارشناسان"
|
||||
branding.developer_credit_text = "امضای عمومی آزمایشی"
|
||||
branding.staff_sidebar_credit_text = "امضای مستقل sidebar آزمایشی"
|
||||
branding.save()
|
||||
client.force_login(user)
|
||||
|
||||
content = client.get(reverse("staff_tickets:list")).content.decode()
|
||||
assert "مرکز ارتباطات سازمانی" in content
|
||||
assert "میز خدمت یکپارچه" in content
|
||||
assert "فضای کارشناسان" in content
|
||||
assert '<div class="staff-developer-credit">امضای مستقل sidebar آزمایشی</div>' in content
|
||||
assert '<footer class="site-footer">امضای عمومی آزمایشی</footer>' in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_login_and_customer_ticket_hero_are_database_backed(client):
|
||||
branding = SiteBranding.load()
|
||||
branding.staff_login_intro_eyebrow = "ورود سازمانی سفارشی"
|
||||
branding.staff_login_intro_heading = "کار بهتر\nپاسخ سریعتر"
|
||||
branding.staff_login_intro_body = "متن معرفی کارشناسان از دیتابیس"
|
||||
branding.customer_tickets_hero_eyebrow = "مرکز ارتباط سفارشی"
|
||||
branding.customer_tickets_hero_heading = "مسیر تازه\nبرای پاسخ"
|
||||
branding.customer_tickets_hero_body = "متن صفحه تیکت از دیتابیس"
|
||||
branding.save()
|
||||
staff_login = client.get(reverse("accounts:staff_login")).content.decode()
|
||||
assert "ورود سازمانی سفارشی" in staff_login and "کار بهتر<br>پاسخ سریعتر" in staff_login
|
||||
customer = User.objects.create_user(username="hero-customer", mobile="09121112222", account_type=User.AccountType.CUSTOMER)
|
||||
client.force_login(customer)
|
||||
tickets = client.get(reverse("tickets:list")).content.decode()
|
||||
assert "مرکز ارتباط سفارشی" in tickets and "مسیر تازه<br>برای پاسخ" in tickets
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_logo_and_pwa_manifest_use_database_values_with_static_fallback(client):
|
||||
branding = SiteBranding.load()
|
||||
branding.company_logo.name = "branding/logo/custom-logo.png"
|
||||
branding.favicon.name = "branding/favicon/custom.ico"
|
||||
branding.pwa_icon.name = "branding/pwa/custom-pwa.png"
|
||||
branding.pwa_name = "سامانه جامع سفارشی"
|
||||
branding.pwa_title = "سامانه سفارشی"
|
||||
branding.pwa_description = "توضیح PWA از دیتابیس"
|
||||
branding.save()
|
||||
login = client.get(reverse("accounts:login")).content.decode()
|
||||
assert "/media/branding/logo/custom-logo.png?v=" in login
|
||||
assert "/media/branding/favicon/custom.ico?v=" in login
|
||||
manifest = client.get(reverse("web_manifest")).json()
|
||||
assert manifest["name"] == "سامانه جامع سفارشی"
|
||||
assert manifest["short_name"] == "سامانه سفارشی"
|
||||
assert manifest["description"] == "توضیح PWA از دیتابیس"
|
||||
assert manifest["icons"][0]["src"] == "/media/branding/pwa/custom-pwa.png"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sms_template_page_includes_global_database_templates(client):
|
||||
user = User.objects.create_superuser(username="sms-admin", password="safe-password", account_type=User.AccountType.STAFF)
|
||||
SMSTemplate.objects.update_or_create(
|
||||
unit=None,
|
||||
event_type=SMSTemplate.Event.STAFF_REPLY,
|
||||
defaults={"title": "الگوی عمومی تست", "body": "پیگیری {ticket_number}: {magic_link}"},
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("staff_tickets:sms_templates"))
|
||||
assert response.status_code == 200
|
||||
assert "الگوی عمومی تست" in response.content.decode()
|
||||
assert "الگوی عمومی سیستم" in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dispatch_history_supports_search_status_and_pagination(client):
|
||||
user = User.objects.create_superuser(username="dispatch-admin", password="safe-password", account_type=User.AccountType.STAFF)
|
||||
unit = OrganizationalUnit.objects.create(name="لوجستیک تست", slug="dispatch-filter-test")
|
||||
group = ContactGroup.objects.create(unit=unit, name="باربریهای تست")
|
||||
category = TicketCategory.objects.create(title="استعلام تست", slug="dispatch-filter-test")
|
||||
for index in range(13):
|
||||
GroupDispatch.objects.create(
|
||||
unit=unit, contact_group=group, created_by=user, category=category,
|
||||
subject=f"استعلام کرایه {index}", body="متن تست", status=GroupDispatch.Status.SENT,
|
||||
)
|
||||
GroupDispatch.objects.create(
|
||||
unit=unit, contact_group=group, created_by=user, category=category,
|
||||
subject="عملیات متفاوت", body="متن تست", status=GroupDispatch.Status.QUEUED,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
first = client.get(reverse("staff_tickets:dispatch_create"))
|
||||
assert first.status_code == 200
|
||||
assert first.context["page"].paginator.num_pages == 2
|
||||
filtered = client.get(reverse("staff_tickets:dispatch_create"), {"q": "متفاوت", "status": GroupDispatch.Status.QUEUED})
|
||||
assert filtered.context["page"].paginator.count == 1
|
||||
assert "عملیات متفاوت" in filtered.content.decode()
|
||||
assert "استعلام کرایه" not in filtered.content.decode()
|
||||
@@ -0,0 +1,78 @@
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
from apps.tickets.category_catalog import CATEGORY_CATALOG
|
||||
from apps.tickets.models import TicketCategory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def customer_catalog(db):
|
||||
call_command("bootstrap")
|
||||
customer = User.objects.create_user(
|
||||
username="category-catalog-customer",
|
||||
password="StrongPass123!",
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
return customer
|
||||
|
||||
|
||||
def test_customer_catalog_is_complete_descriptive_and_limited_to_sales_and_finance(customer_catalog):
|
||||
visible_units = OrganizationalUnit.objects.filter(
|
||||
is_active=True, accepts_external_tickets=True, show_in_customer_form=True
|
||||
)
|
||||
assert set(visible_units.values_list("slug", flat=True)) == {"sales", "finance"}
|
||||
|
||||
for unit_slug, expected_rows in CATEGORY_CATALOG.items():
|
||||
expected_slugs = {row[1] for row in expected_rows}
|
||||
categories = TicketCategory.objects.filter(
|
||||
unit__slug=unit_slug, is_active=True, show_in_customer_form=True
|
||||
)
|
||||
assert set(categories.values_list("slug", flat=True)) == expected_slugs
|
||||
assert not categories.filter(description="").exists()
|
||||
assert not categories.filter(search_keywords="").exists()
|
||||
|
||||
assert not TicketCategory.objects.filter(
|
||||
slug="e2e-sms", show_in_customer_form=True
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "expected_slug", "expected_unit"),
|
||||
[
|
||||
("بار من کجاست و کی میرسه", "loading-shipping-tracking", "sales"),
|
||||
("کسری و خرابی کالای تحویلی", "delivery-issue", "sales"),
|
||||
("کپی فاکتور رسمی", "invoice", "finance"),
|
||||
("چک صیادی برگشت خورده", "cheque-guarantee", "finance"),
|
||||
("تسویه حساب تامین کننده", "supplier-settlement", "finance"),
|
||||
],
|
||||
)
|
||||
def test_customer_search_routes_natural_language_to_expected_category(
|
||||
client, customer_catalog, query, expected_slug, expected_unit
|
||||
):
|
||||
client.force_login(customer_catalog)
|
||||
response = client.get(reverse("tickets:new_search"), {"q": query})
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
expected = TicketCategory.objects.get(slug=expected_slug)
|
||||
assert results
|
||||
assert results[0]["category_id"] == expected.pk
|
||||
assert results[0]["unit"] == OrganizationalUnit.objects.get(slug=expected_unit).name
|
||||
assert results[0]["description"] == expected.description[:120]
|
||||
|
||||
|
||||
def test_bootstrap_keeps_customer_catalog_idempotent(customer_catalog):
|
||||
before = {
|
||||
slug: TicketCategory.objects.get(slug=slug).pk
|
||||
for rows in CATEGORY_CATALOG.values()
|
||||
for _, slug, *_ in rows
|
||||
}
|
||||
call_command("bootstrap")
|
||||
after = {
|
||||
slug: TicketCategory.objects.get(slug=slug).pk
|
||||
for rows in CATEGORY_CATALOG.values()
|
||||
for _, slug, *_ in rows
|
||||
}
|
||||
assert after == before
|
||||
@@ -0,0 +1,162 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from django.contrib import admin
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.staff.admin import StaffProfileAdmin
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def customer(db):
|
||||
return User.objects.create_user(username="unit-customer", password="StrongPass123!", account_type=User.AccountType.CUSTOMER)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unit_categories(db):
|
||||
sales=OrganizationalUnit.objects.create(name="فروش تست واحدی",slug="test-unit-sales",is_active=True,accepts_external_tickets=True)
|
||||
finance=OrganizationalUnit.objects.create(name="حسابداری تست واحدی",slug="test-unit-finance",is_active=True,accepts_external_tickets=True)
|
||||
sales_category=TicketCategory.objects.create(unit=sales,title="خرید محصول",slug="buy-product")
|
||||
finance_category=TicketCategory.objects.create(unit=finance,title="اسناد مالی تست",slug="test-financial-documents")
|
||||
return sales,finance,sales_category,finance_category
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_options_are_isolated_by_unit(client, customer, unit_categories):
|
||||
sales,finance,sales_category,finance_category=unit_categories
|
||||
client.force_login(customer)
|
||||
response=client.get(reverse("tickets:new_options"),{"unit":finance.pk})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["categories"] == [{"id":finance_category.pk,"label":"اسناد مالی تست"}]
|
||||
assert sales_category.title not in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_creates_ticket_for_selected_unit(client, customer, unit_categories):
|
||||
sales,finance,sales_category,finance_category=unit_categories
|
||||
client.force_login(customer)
|
||||
response=client.post(reverse("tickets:new"),{"unit":finance.pk,"category":finance_category.pk,"subject":"ارسال سند حسابداری","body":"لطفاً سند را بررسی کنید."})
|
||||
assert response.status_code == 302
|
||||
ticket=Ticket.objects.get(customer=customer)
|
||||
assert ticket.owning_unit == finance
|
||||
assert ticket.assigned_unit == finance
|
||||
assert ticket.category == finance_category
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_tampered_cross_unit_category_is_rejected(client, customer, unit_categories):
|
||||
sales,finance,sales_category,finance_category=unit_categories
|
||||
client.force_login(customer)
|
||||
response=client.post(reverse("tickets:new"),{"unit":finance.pk,"category":sales_category.pk,"subject":"درخواست نامعتبر آزمایشی","body":"این ترکیب نباید ذخیره شود."})
|
||||
assert response.status_code == 200
|
||||
assert Ticket.objects.count() == 0
|
||||
assert "category" in response.context["form"].errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_closed_unit_is_not_exposed(client, customer, unit_categories):
|
||||
sales,finance,_,_=unit_categories
|
||||
finance.accepts_external_tickets=False; finance.save(update_fields=["accepts_external_tickets"])
|
||||
client.force_login(customer)
|
||||
assert client.get(reverse("tickets:new_options"),{"unit":finance.pk}).status_code == 404
|
||||
response=client.get(reverse("tickets:new"))
|
||||
assert finance not in response.context["form"].fields["unit"].queryset
|
||||
assert sales in response.context["form"].fields["unit"].queryset
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_persian_intent_search_finds_semantic_category(client, customer, unit_categories):
|
||||
sales,finance,_,finance_category=unit_categories
|
||||
finance_category.title="رسید پرداخت آزمایشی"; finance_category.description="بررسی پرداخت یکتای آزمایش مشتری"; finance_category.search_keywords="فیش یکتای واحد تست"; finance_category.save()
|
||||
client.force_login(customer)
|
||||
response=client.get(reverse("tickets:new_search"),{"q":"فیش یکتای واحد تست"})
|
||||
assert response.status_code == 200
|
||||
results=response.json()["results"]
|
||||
assert any(item["unit_id"]==finance.pk and ("پرداخت" in item["category"] or "فیش" in item["category"]) for item in results)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_search_uses_database_keywords_and_display_order(client, customer, unit_categories):
|
||||
sales,finance,sales_category,finance_category=unit_categories
|
||||
sales.sort_order=20; sales.save(update_fields=["sort_order"])
|
||||
finance.sort_order=5; finance.save(update_fields=["sort_order"])
|
||||
sales_category.search_keywords="محصول سفارشی آینده"; sales_category.save(update_fields=["search_keywords"])
|
||||
finance_category.search_keywords="محصول سفارشی آینده"; finance_category.save(update_fields=["search_keywords"])
|
||||
client.force_login(customer)
|
||||
form=client.get(reverse("tickets:new")).context["form"]
|
||||
assert list(form.fields["unit"].queryset.filter(pk__in=[sales.pk,finance.pk]).values_list("pk",flat=True)) == [finance.pk,sales.pk]
|
||||
results=client.get(reverse("tickets:new_search"),{"q":"محصول سفارشی آینده"}).json()["results"]
|
||||
assert {item["category_id"] for item in results} >= {sales_category.pk,finance_category.pk}
|
||||
later=TicketCategory.objects.create(unit=finance,title="دسته دیرتر",slug="ordered-later",sort_order=30)
|
||||
finance_category.sort_order=2; finance_category.save(update_fields=["sort_order"])
|
||||
options=client.get(reverse("tickets:new_options"),{"unit":finance.pk}).json()["categories"]
|
||||
relevant=[item["id"] for item in options if item["id"] in (finance_category.pk,later.pk)]
|
||||
assert relevant == [finance_category.pk,later.pk]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_admin_visibility_flags_hide_units_and_categories_everywhere(client, customer, unit_categories):
|
||||
sales,finance,sales_category,finance_category=unit_categories
|
||||
sales.show_in_customer_form=False; sales.save(update_fields=["show_in_customer_form"])
|
||||
finance_category.show_in_customer_form=False; finance_category.search_keywords="عبارت کاملاً مخفی"; finance_category.save(update_fields=["show_in_customer_form","search_keywords"])
|
||||
client.force_login(customer)
|
||||
form=client.get(reverse("tickets:new")).context["form"]
|
||||
assert sales not in form.fields["unit"].queryset
|
||||
assert client.get(reverse("tickets:new_options"),{"unit":sales.pk}).status_code == 404
|
||||
assert client.get(reverse("tickets:new_options"),{"unit":finance.pk}).json()["categories"] == []
|
||||
assert client.get(reverse("tickets:new_search"),{"q":"عبارت کاملاً مخفی"}).json()["results"] == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_public_routing_layout_is_compact_and_subject_starts_empty(client, customer, unit_categories):
|
||||
client.force_login(customer)
|
||||
response = client.get(reverse("tickets:new"))
|
||||
assert response.status_code == 200
|
||||
form = response.context["form"]
|
||||
assert not form["subject"].value()
|
||||
assert form.fields["subject"].required
|
||||
assert form.fields["subject"].widget.attrs["autocomplete"] == "off"
|
||||
html = response.content.decode()
|
||||
assert 'class="routing-selectors" data-routing-selectors' in html
|
||||
assert "موضوع را بهصورت دستی" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unit_options_order_experts_by_numeric_staff_order(client, customer, unit_categories):
|
||||
sales, _, _, _ = unit_categories
|
||||
profiles = []
|
||||
for username, display_name, order in (
|
||||
("expert-last", "کارشناس سوم", 30),
|
||||
("expert-first", "کارشناس اول", 10),
|
||||
("expert-middle", "کارشناس دوم", 20),
|
||||
):
|
||||
user = User.objects.create_user(username=username, account_type=User.AccountType.STAFF)
|
||||
profiles.append(
|
||||
StaffProfile.objects.create(
|
||||
user=user, primary_unit=sales, display_name=display_name,
|
||||
sort_order=order, is_active=True, is_customer_selectable=True,
|
||||
)
|
||||
)
|
||||
client.force_login(customer)
|
||||
payload = client.get(reverse("tickets:new_options"), {"unit": sales.pk}).json()
|
||||
assert [item["name"] for item in payload["experts"]] == ["کارشناس اول", "کارشناس دوم", "کارشناس سوم"]
|
||||
assert list(
|
||||
StaffProfile.objects.filter(pk__in=[profile.pk for profile in profiles])
|
||||
.order_by("sort_order")
|
||||
.values_list("sort_order", flat=True)
|
||||
) == [10, 20, 30]
|
||||
|
||||
|
||||
def test_search_javascript_never_autofills_subject(settings):
|
||||
source = (Path(settings.BASE_DIR) / "static" / "js" / "ticket-intent-search.js").read_text(encoding="utf-8")
|
||||
assert "subject.value" not in source
|
||||
assert "[name=\"subject\"]" not in source
|
||||
|
||||
|
||||
def test_staff_order_is_editable_directly_in_admin_list():
|
||||
profile_admin = StaffProfileAdmin(StaffProfile, admin.site)
|
||||
assert "sort_order" in profile_admin.list_display
|
||||
assert "sort_order" in profile_admin.list_editable
|
||||
assert StaffProfile._meta.get_field("sort_order").verbose_name == "ترتیب نمایش در انتخاب کارشناس"
|
||||
@@ -0,0 +1,52 @@
|
||||
import base64
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from apps.attachments.delivery import signed_media_redirect
|
||||
from apps.integrations.bale.providers import get_bale_provider
|
||||
|
||||
|
||||
def test_signed_media_redirect_matches_nginx_secure_link(settings):
|
||||
settings.MEDIA_SIGNING_SECRET = "deployment-test-secret-with-enough-entropy"
|
||||
response = signed_media_redirect("tickets/2026/09/example.pdf", lifetime=300)
|
||||
parsed = urlparse(response.url)
|
||||
query = parse_qs(parsed.query)
|
||||
expires = query["expires"][0]
|
||||
expected = hashlib.md5(
|
||||
f"{expires}{parsed.path} {settings.MEDIA_SIGNING_SECRET}".encode(),
|
||||
usedforsecurity=False,
|
||||
).digest()
|
||||
expected = base64.urlsafe_b64encode(expected).rstrip(b"=").decode()
|
||||
assert response.status_code == 302
|
||||
assert query["md5"] == [expected]
|
||||
|
||||
|
||||
def test_bale_missing_token_is_not_silently_successful(settings):
|
||||
settings.DEBUG = False
|
||||
settings.BALE_BOT_TOKEN = ""
|
||||
with pytest.raises(ImproperlyConfigured, match="BALE_BOT_TOKEN"):
|
||||
get_bale_provider()
|
||||
|
||||
|
||||
def test_nginx_serves_assets_without_proxying_django():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
nginx = (root / "docker" / "nginx.conf").read_text(encoding="utf-8")
|
||||
compose = (root / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
assert "location /static/" in nginx
|
||||
assert "location ^~ /media/branding/" in nginx
|
||||
assert "location /protected-media/" in nginx
|
||||
assert "secure_link_md5" in nginx
|
||||
assert "proxy_pass" not in nginx
|
||||
assert "media_data:/app/media:ro" in compose
|
||||
assert "notification-worker:" in compose
|
||||
assert " db:" not in compose
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_worker_command_starts_and_exits_in_once_mode():
|
||||
call_command("process_notifications", once=True)
|
||||
@@ -0,0 +1,392 @@
|
||||
import pytest
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from apps.attachments.models import Attachment, AttachmentPolicy
|
||||
from apps.accounts.models import User, normalize_mobile
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.staff.models import StaffProfile
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.tickets.services import claim_ticket, create_ticket, delete_ticket, reply_ticket
|
||||
|
||||
@pytest.fixture
|
||||
def category(db):
|
||||
return TicketCategory.objects.create(title="قیمت", slug="price")
|
||||
|
||||
@pytest.fixture
|
||||
def customer(db):
|
||||
return User.objects.create_user(username="customer", mobile="09121234567", account_type="CUSTOMER")
|
||||
|
||||
def make_staff(username):
|
||||
mobile = "09120000001" if username == "a" else "09120000002"
|
||||
user = User.objects.create_user(username=username, mobile=mobile, account_type="STAFF")
|
||||
for code in ["view_ticket", "claim_ticket", "change_ticket"]:
|
||||
user.user_permissions.add(Permission.objects.get(codename=code, content_type__app_label="tickets"))
|
||||
return user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mobile_normalization():
|
||||
assert normalize_mobile("0912 123 4567") == "+989121234567"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_reply_enqueues_bale_group_notification(category, customer):
|
||||
ticket = create_ticket(customer=customer, category=category, subject="پیگیری سفارش", body="پیام نخست")
|
||||
message = reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ تازه مشتری")
|
||||
job = NotificationJob.objects.get(event_type="CUSTOMER_TICKET_REPLY", ticket=ticket)
|
||||
assert job.channel == NotificationJob.Channel.BALE
|
||||
assert job.payload == {"message_id": message.pk,"message_count":1}
|
||||
assert job.next_attempt_at>=timezone.now()+timedelta(seconds=595)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_ticket_and_reply_bale_events_always_target_group(category, customer, settings, monkeypatch):
|
||||
from apps.notifications.management.commands.process_notifications import _send_bale
|
||||
settings.BALE_GROUP_CHAT_ID = "group-123"
|
||||
sent = []
|
||||
class Provider:
|
||||
def send_message(self, chat_id, text):
|
||||
sent.append((chat_id, text))
|
||||
return type("Result", (), {"success": True})()
|
||||
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_bale_provider", lambda: Provider())
|
||||
from apps.customers.models import CustomerProfile
|
||||
profile,_=CustomerProfile.objects.get_or_create(user=customer)
|
||||
profile.first_name="سارا"; profile.last_name="احمدی"; profile.city="تهران"; profile.save()
|
||||
ticket = create_ticket(customer=customer, category=category, subject="اعلان بله", body="پیام نخست")
|
||||
created_job = NotificationJob.objects.get(ticket=ticket, event_type="NEW_CUSTOMER_TICKET")
|
||||
_send_bale(created_job)
|
||||
reply = reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ مشتری")
|
||||
reply_job = NotificationJob.objects.get(ticket=ticket, event_type="CUSTOMER_TICKET_REPLY")
|
||||
_send_bale(reply_job)
|
||||
assert [chat_id for chat_id, _ in sent] == ["group-123", "group-123"]
|
||||
assert "تیکت جدید مشتری" in sent[0][1]
|
||||
assert "پاسخ جدید مشتری" in sent[1][1]
|
||||
assert reply.body in sent[1][1]
|
||||
for text in (sent[0][1],sent[1][1]):
|
||||
assert "👤 سارا احمدی" in text
|
||||
assert f"📞 {customer.local_mobile}" in text
|
||||
assert f"/staff/tickets/{ticket.public_id}/" in text
|
||||
assert "🎫" in text and "📌 اعلان بله" in text and "📝" in text
|
||||
assert "📍 تهران" in sent[0][1]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_bale_replies_are_debounced_by_database_policy(category,customer):
|
||||
from apps.notifications.models import NotificationPolicy
|
||||
policy=NotificationPolicy.objects.get(key="CUSTOMER_REPLY_BALE")
|
||||
policy.quiet_period_seconds=720; policy.save()
|
||||
ticket=create_ticket(customer=customer,category=category,subject="چت مشتری",body="شروع")
|
||||
first=reply_ticket(ticket_id=ticket.pk,user=customer,body="پیام اول")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_REPLY")
|
||||
first_due=job.next_attempt_at
|
||||
second=reply_ticket(ticket_id=ticket.pk,user=customer,body="پیام دوم")
|
||||
job.refresh_from_db()
|
||||
assert NotificationJob.objects.filter(ticket=ticket,event_type="CUSTOMER_TICKET_REPLY").count()==1
|
||||
assert job.payload=={"message_id":second.pk,"message_count":2}
|
||||
assert job.next_attempt_at>=first_due and job.next_attempt_at>=timezone.now()+timedelta(seconds=715)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reopened_ticket_bale_notification_is_immediate(category,customer):
|
||||
ticket=create_ticket(customer=customer,category=category,subject="بازگشایی",body="شروع")
|
||||
Ticket.objects.filter(pk=ticket.pk).update(status=Ticket.Status.CLOSED)
|
||||
message=reply_ticket(ticket_id=ticket.pk,user=customer,body="لطفاً دوباره بررسی شود")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_REOPENED")
|
||||
assert job.payload=={"message_id":message.pk,"message_count":1}
|
||||
assert job.next_attempt_at<=timezone.now()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bale_worker_defers_active_customer_chat(category,customer):
|
||||
from apps.notifications.management.commands.process_notifications import _prepare_customer_reply_bale
|
||||
ticket=create_ticket(customer=customer,category=category,subject="گفتگوی فعال",body="شروع")
|
||||
reply_ticket(ticket_id=ticket.pk,user=customer,body="هنوز در حال گفتگو")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_REPLY")
|
||||
NotificationJob.objects.filter(pk=job.pk).update(status="PROCESSING",next_attempt_at=timezone.now())
|
||||
assert _prepare_customer_reply_bale(job) is False
|
||||
job.refresh_from_db()
|
||||
assert job.status=="PENDING" and job.last_error=="customer_chat_active"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bale_ticket_excerpt_is_compact_and_whitespace_normalized(category,customer,settings):
|
||||
from apps.notifications.management.commands.process_notifications import _bale_ticket_text
|
||||
settings.PUBLIC_BASE_URL="https://support.example.com"
|
||||
long_body=("متن طولانی با فاصله\n"*30)
|
||||
ticket=create_ticket(customer=customer,category=category,subject="خلاصه کوتاه",body=long_body)
|
||||
text=_bale_ticket_text(ticket)
|
||||
excerpt=text.split("📝 ",1)[1].split("\n",1)[0]
|
||||
assert len(excerpt)<=280 and excerpt.endswith("…")
|
||||
assert " " not in excerpt and "https://support.example.com/staff/tickets/" in text
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ticket_full_delete_requires_manager_permission_and_denies_sales_expert(category, customer):
|
||||
ticket = create_ticket(customer=customer, category=category, subject="حذف پرونده", body="متن اولیه")
|
||||
expert = User.objects.create_user(username="delete-expert", mobile="09120000003", account_type="STAFF")
|
||||
StaffProfile.objects.create(user=expert, display_name="کارشناس", role=StaffProfile.Role.SALES_EXPERT)
|
||||
expert.user_permissions.add(Permission.objects.get(codename="delete_ticket", content_type__app_label="tickets"))
|
||||
with pytest.raises(PermissionDenied):
|
||||
delete_ticket(ticket_id=ticket.pk, user=expert)
|
||||
manager = User.objects.create_user(username="delete-manager", mobile="09120000004", account_type="STAFF")
|
||||
StaffProfile.objects.create(user=manager, display_name="مدیر", role=StaffProfile.Role.SALES_MANAGER)
|
||||
for codename in ("delete_ticket", "view_all_tickets"):
|
||||
manager.user_permissions.add(Permission.objects.get(codename=codename, content_type__app_label="tickets"))
|
||||
delete_ticket(ticket_id=ticket.pk, user=manager)
|
||||
assert not Ticket.objects.filter(pk=ticket.pk).exists()
|
||||
assert not NotificationJob.objects.filter(ticket_id=ticket.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(DEBUG=True, DEV_OTP_MOBILE="+989202202129", DEV_OTP_CODE="12345")
|
||||
def test_fixed_otp_is_available_for_development_only():
|
||||
from apps.accounts.models import OTPChallenge
|
||||
challenge, code = OTPChallenge.issue("09202202129")
|
||||
assert code == "12345"
|
||||
assert challenge.verify("12345")
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(DEBUG=False, DEV_OTP_MOBILE="+989202202129", DEV_OTP_CODE="12345")
|
||||
def test_fixed_otp_is_disabled_outside_development():
|
||||
from apps.accounts.models import OTPChallenge
|
||||
_, code = OTPChallenge.issue("09202202129")
|
||||
assert code != "12345"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_cannot_view_others_ticket(category, customer):
|
||||
other = User.objects.create_user(username="other", mobile="09121111111", account_type="CUSTOMER")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="موضوع معتبر", body="متن")
|
||||
assert TicketPolicy.can_view(customer, ticket)
|
||||
assert not TicketPolicy.can_view(other, ticket)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_selected_expert_is_private(category, customer):
|
||||
a, b = make_staff("a"), make_staff("b")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="موضوع خصوصی", selected_expert=a, body="متن")
|
||||
assert TicketPolicy.can_view(a, ticket)
|
||||
assert not TicketPolicy.can_view(b, ticket)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_claim_locks_ticket(category, customer):
|
||||
a, b = make_staff("a"), make_staff("b")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="موضوع عمومی", body="متن")
|
||||
claim_ticket(ticket.pk, a)
|
||||
ticket.refresh_from_db()
|
||||
assert ticket.owner == a and ticket.status == Ticket.Status.OPEN
|
||||
with pytest.raises(PermissionDenied):
|
||||
claim_ticket(ticket.pk, b)
|
||||
assert not TicketPolicy.can_view(b, ticket)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_closed_customer_reply_reopens(category, customer):
|
||||
ticket = create_ticket(customer=customer, category=category, subject="موضوع بسته", body="متن")
|
||||
ticket.status = Ticket.Status.CLOSED
|
||||
ticket.save()
|
||||
reply_ticket(ticket_id=ticket.pk, user=customer, body="دوباره نیاز دارم")
|
||||
ticket.refresh_from_db()
|
||||
assert ticket.status == Ticket.Status.REOPENED
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_reply_waits_customer(category, customer):
|
||||
a = make_staff("a")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="پاسخ کارشناس", body="متن")
|
||||
claim_ticket(ticket.pk, a)
|
||||
reply_ticket(ticket_id=ticket.pk, user=a, body="پاسخ")
|
||||
ticket.refresh_from_db()
|
||||
assert ticket.status == Ticket.Status.WAITING_CUSTOMER
|
||||
assert ticket.first_response_at
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_attachment_uuid_path_fits_storage_field(tmp_path, category, customer):
|
||||
AttachmentPolicy.objects.create(attachment_type="IMAGE", max_file_size=10 * 1024 * 1024, allowed_extensions=[".png"])
|
||||
png=base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||||
upload = SimpleUploadedFile("customer-image.png", png, content_type="image/png")
|
||||
with override_settings(MEDIA_ROOT=tmp_path):
|
||||
ticket = create_ticket(customer=customer, category=category, subject="درخواست تصویری", body="تصویر پیوست شد", files=[upload])
|
||||
attachment = ticket.ticket_messages.get().attachments.get()
|
||||
assert attachment.file.name.startswith("tickets/")
|
||||
assert len(attachment.file.name) <= 500
|
||||
assert attachment.file.storage.exists(attachment.file.name)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_spoofed_image_is_rejected(category, customer):
|
||||
from django.core.exceptions import ValidationError
|
||||
AttachmentPolicy.objects.create(attachment_type="IMAGE",max_file_size=1024,allowed_extensions=[".png"])
|
||||
upload=SimpleUploadedFile("fake.png",b"not an image",content_type="image/png")
|
||||
with pytest.raises(ValidationError): create_ticket(customer=customer,category=category,subject="تصویر نامعتبر",body="فایل",files=[upload])
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_selected_expert_can_transfer_and_old_access_is_removed(category,customer):
|
||||
from apps.tickets.services import transfer_ticket
|
||||
a,b=make_staff("a"),make_staff("b")
|
||||
for user in (a,b): user.user_permissions.add(Permission.objects.get(codename="transfer_ticket",content_type__app_label="tickets"))
|
||||
ticket=create_ticket(customer=customer,category=category,subject="انتقال انتخابی",selected_expert=a,body="متن")
|
||||
transfer_ticket(ticket.pk,a,b); ticket.refresh_from_db()
|
||||
assert ticket.owner==b and ticket.selected_expert is None
|
||||
assert not TicketPolicy.can_view(a,ticket) and TicketPolicy.can_view(b,ticket)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_internal_note_is_hidden_and_does_not_enqueue_sms(category,customer):
|
||||
from apps.notifications.models import NotificationJob
|
||||
a=make_staff("a"); a.user_permissions.add(Permission.objects.get(codename="add_internal_note",content_type__app_label="messaging"))
|
||||
ticket=create_ticket(customer=customer,category=category,subject="یادداشت داخلی",body="متن"); claim_ticket(ticket.pk,a)
|
||||
sms_count=NotificationJob.objects.filter(channel="SMS").count()
|
||||
msg=reply_ticket(ticket_id=ticket.pk,user=a,body="محرمانه",internal=True)
|
||||
assert msg.is_internal and NotificationJob.objects.filter(channel="SMS").count()==sms_count
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_can_soft_delete_own_message_but_not_another_authors(category,customer):
|
||||
from apps.messaging.services import soft_delete_message
|
||||
staff=make_staff("a")
|
||||
ticket=create_ticket(customer=customer,category=category,subject="حذف پیام مشتری",body="پیام خود مشتری")
|
||||
own=ticket.ticket_messages.get()
|
||||
deleted=soft_delete_message(own.pk,customer)
|
||||
assert deleted.deleted_at is not None and deleted.deleted_by==customer
|
||||
staff_message=reply_ticket(ticket_id=ticket.pk,user=staff,body="پاسخ کارشناس")
|
||||
with pytest.raises(PermissionDenied): soft_delete_message(staff_message.pk,customer)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_reply_sms_is_debounced_using_database_policy(category,customer):
|
||||
from apps.notifications.models import NotificationJob,NotificationPolicy
|
||||
from django.utils import timezone
|
||||
policy=NotificationPolicy.objects.get(key="STAFF_REPLY_SMS")
|
||||
policy.quiet_period_seconds=420; policy.save()
|
||||
a=make_staff("a"); ticket=create_ticket(customer=customer,category=category,subject="کنترل پیامک پاسخ",selected_expert=a,body="متن")
|
||||
first=reply_ticket(ticket_id=ticket.pk,user=a,body="پاسخ اول")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="STAFF_REPLY")
|
||||
first_due=job.next_attempt_at
|
||||
second=reply_ticket(ticket_id=ticket.pk,user=a,body="پاسخ دوم")
|
||||
job.refresh_from_db()
|
||||
assert NotificationJob.objects.filter(ticket=ticket,event_type="STAFF_REPLY").count()==1
|
||||
assert job.payload=={"latest_message_id":second.pk} and job.next_attempt_at>=first_due
|
||||
assert job.next_attempt_at>=timezone.now()+timedelta(seconds=415)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_reply_cancels_pending_staff_sms(category,customer):
|
||||
a=make_staff("a"); ticket=create_ticket(customer=customer,category=category,subject="گفتوگوی فعال",selected_expert=a,body="متن")
|
||||
reply_ticket(ticket_id=ticket.pk,user=a,body="پاسخ کارشناس")
|
||||
reply_ticket(ticket_id=ticket.pk,user=customer,body="پاسخ سریع مشتری")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="STAFF_REPLY")
|
||||
assert job.status==NotificationJob.Status.CANCELLED and job.last_error=="customer_replied"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_due_unread_staff_reply_is_sent_once(category,customer,monkeypatch):
|
||||
from django.core.management import call_command
|
||||
from apps.integrations.sms.base import SMSResult
|
||||
from apps.notifications.management.commands import process_notifications
|
||||
a=make_staff("a"); ticket=create_ticket(customer=customer,category=category,subject="پاسخ خوانده نشده",selected_expert=a,body="متن")
|
||||
reply_ticket(ticket_id=ticket.pk,user=a,body="پاسخ کارشناس")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="STAFF_REPLY")
|
||||
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now()-timedelta(seconds=1))
|
||||
ticket.last_activity_at=timezone.now()-timedelta(hours=1); ticket.save(update_fields=["last_activity_at"])
|
||||
sent=[]
|
||||
class Provider:
|
||||
def send_message(self,mobile,message): sent.append((mobile,message)); return SMSResult(True,"ok")
|
||||
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider())
|
||||
call_command("process_notifications",once=True,job_id=job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.status==NotificationJob.Status.SENT and len(sent)==1
|
||||
call_command("process_notifications",once=True,job_id=job.pk)
|
||||
assert len(sent)==1
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_ticket_created_sms_has_seven_day_magic_link(category,customer,settings,monkeypatch):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
from apps.integrations.sms.base import SMSResult
|
||||
from apps.notifications.management.commands import process_notifications
|
||||
from apps.notifications.models import NotificationJob
|
||||
settings.PUBLIC_BASE_URL="https://support.example.com"
|
||||
settings.MAGIC_LINK_TTL_DAYS=7
|
||||
ticket=create_ticket(customer=customer,category=category,subject="پیام تشکر",body="متن")
|
||||
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
|
||||
captured={}
|
||||
class Provider:
|
||||
def send_message(self,mobile,message): captured.update(mobile=mobile,message=message); return SMSResult(True,"ok")
|
||||
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider())
|
||||
before=timezone.now()
|
||||
result,link=process_notifications._send_sms(job)
|
||||
assert result.success and link.created_reason=="customer_created_ticket"
|
||||
assert before+timedelta(days=7)-timedelta(seconds=1) <= link.expires_at <= before+timedelta(days=7)+timedelta(seconds=1)
|
||||
assert "از ثبت درخواست شما متشکریم" in captured["message"]
|
||||
assert "پس از بررسی و پاسخ کارشناس، از طریق پیامک" in captured["message"]
|
||||
assert "https://support.example.com/t/" in captured["message"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sms_templates_are_database_managed_and_distinguish_ticket_creator():
|
||||
from apps.notifications.models import SMSTemplate
|
||||
customer_text=SMSTemplate.objects.get(event_type="CUSTOMER_TICKET_CREATED").body
|
||||
staff_text=SMSTemplate.objects.get(event_type="STAFF_TICKET_CREATED").body
|
||||
assert customer_text!=staff_text
|
||||
assert "از ثبت درخواست شما متشکریم" in customer_text
|
||||
assert "توسط واحد فروش برای شما ثبت شد" in staff_text
|
||||
assert "{magic_link}" in customer_text and "{first_name}" in customer_text
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_magic_link_is_ten_character_base36_and_retries_collision_without_select(category,customer,monkeypatch):
|
||||
import re
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
ticket=create_ticket(customer=customer,category=category,subject="توکن کوتاه",body="متن")
|
||||
TicketMagicLink.objects.create(ticket=ticket,customer=customer,token_hash=TicketMagicLink.digest("aaaaaaaaaa"),expires_at=timezone.now()+__import__('datetime').timedelta(days=7))
|
||||
values=iter(["aaaaaaaaaa","b2c3d4e5f6"])
|
||||
monkeypatch.setattr(TicketMagicLink,"generate_secure_token",staticmethod(lambda length=10:next(values)))
|
||||
with CaptureQueriesContext(connection) as queries: token=TicketMagicLink.issue(ticket)
|
||||
assert token=="b2c3d4e5f6" and re.fullmatch(r"[a-z0-9]{10}",token)
|
||||
assert not any(query["sql"].lstrip().upper().startswith("SELECT") for query in queries)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_audio_only_webm_is_saved_as_voice(category,customer,monkeypatch,tmp_path,settings):
|
||||
from apps.attachments.services import save_attachments
|
||||
settings.MEDIA_ROOT=tmp_path
|
||||
AttachmentPolicy.objects.update_or_create(attachment_type="VOICE",defaults={"max_file_size":1024*1024,"allowed_extensions":[".webm"],"allowed_mime_types":[]})
|
||||
ticket=create_ticket(customer=customer,category=category,subject="ضبط صدا",body="متن")
|
||||
message=ticket.ticket_messages.first()
|
||||
monkeypatch.setattr("apps.attachments.services.magic.from_buffer",lambda *args,**kwargs:"video/webm")
|
||||
upload=SimpleUploadedFile("voice.webm",b"webm-audio-content",content_type="audio/webm")
|
||||
attachment=save_attachments(message,[upload],customer)[0]
|
||||
assert attachment.attachment_type=="VOICE" and attachment.extension==".webm"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_webotp_sms_format_is_origin_bound(settings):
|
||||
from apps.integrations.sms.providers import webotp_message
|
||||
settings.WEBOTP_HOST="example.com"
|
||||
assert webotp_message("123456")=="کد شما: 123456\n@example.com #123456"
|
||||
|
||||
@override_settings(SMS_PROVIDER_CLASS="apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider")
|
||||
def test_sms_provider_contract_and_default(settings):
|
||||
from apps.integrations.sms.base import SMSProvider
|
||||
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
|
||||
from apps.integrations.sms.factory import get_sms_provider,reset_sms_provider
|
||||
assert issubclass(ZeroZeroNinetyEightSMSProvider,SMSProvider)
|
||||
reset_sms_provider()
|
||||
assert isinstance(get_sms_provider(),ZeroZeroNinetyEightSMSProvider)
|
||||
reset_sms_provider()
|
||||
|
||||
def test_behin_provider_maps_send_array_contract(settings):
|
||||
from apps.integrations.sms.behin import BehinSMSProvider
|
||||
settings.BEHIN_SMS_USERNAME="user"; settings.BEHIN_SMS_PASSWORD="pass"; settings.BEHIN_SMS_SPECIAL_NUMBER="3000"
|
||||
calls={}
|
||||
class Service:
|
||||
def SendArray(self,**kwargs): calls.update(kwargs); return [987654]
|
||||
class Client: service=Service()
|
||||
provider=BehinSMSProvider(); provider._client=Client()
|
||||
result=provider.send_message("+989202202129","پیام آزمایشی")
|
||||
assert result.success and "987654" in result.provider_id
|
||||
assert calls["RecipientNumber"]=={"string":["+989202202129"]}
|
||||
assert calls["CheckingMessageID"]=={"long":[0]}
|
||||
assert calls["SpecialNumber"]=="3000" and calls["IsFlashMessage"] is False
|
||||
|
||||
def test_0098_provider_maps_http_contract(settings,monkeypatch):
|
||||
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
|
||||
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
|
||||
calls={}
|
||||
class Response:
|
||||
text="42"
|
||||
def raise_for_status(self): return None
|
||||
def fake_get(url,**kwargs): calls.update(url=url,**kwargs); return Response()
|
||||
monkeypatch.setattr("apps.integrations.sms.sms0098.requests.get",fake_get)
|
||||
result=ZeroZeroNinetyEightSMSProvider().send_message("09202202129","پیام آزمایشی")
|
||||
assert result.success and result.provider_id=="42"
|
||||
assert calls["params"]["TO"]==9202202129
|
||||
assert calls["params"]["TEXT"]=="پیام آزمایشی"
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
from config.env import load_env_file, load_project_environment
|
||||
|
||||
|
||||
def test_env_file_does_not_override_injected_environment(tmp_path, monkeypatch):
|
||||
env_file = tmp_path / ".env.local"
|
||||
env_file.write_text("EXISTING=from-file\nNEW_VALUE='loaded locally'\n", encoding="utf-8")
|
||||
monkeypatch.setenv("EXISTING", "from-server")
|
||||
monkeypatch.delenv("NEW_VALUE", raising=False)
|
||||
assert load_env_file(env_file) is True
|
||||
assert __import__("os").environ["EXISTING"] == "from-server"
|
||||
assert __import__("os").environ["NEW_VALUE"] == "loaded locally"
|
||||
|
||||
|
||||
def test_environment_selects_local_for_dev_and_env_for_prod(tmp_path, monkeypatch):
|
||||
(tmp_path / ".env.local").write_text("SELECTED_ENV=local\n", encoding="utf-8")
|
||||
(tmp_path / ".env").write_text("SELECTED_ENV=production\n", encoding="utf-8")
|
||||
monkeypatch.delenv("DJANGO_ENV_FILE", raising=False)
|
||||
monkeypatch.delenv("SELECTED_ENV", raising=False)
|
||||
monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "config.settings.dev")
|
||||
assert load_project_environment(Path(tmp_path)) == tmp_path / ".env.local"
|
||||
assert __import__("os").environ["SELECTED_ENV"] == "local"
|
||||
monkeypatch.delenv("SELECTED_ENV")
|
||||
monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "config.settings.prod")
|
||||
assert load_project_environment(Path(tmp_path)) == tmp_path / ".env"
|
||||
assert __import__("os").environ["SELECTED_ENV"] == "production"
|
||||
|
||||
|
||||
def test_explicit_environment_file_can_be_selected(tmp_path, monkeypatch):
|
||||
custom = tmp_path / "server.env"
|
||||
custom.write_text("CUSTOM_SERVER_VALUE=yes\n", encoding="utf-8")
|
||||
monkeypatch.setenv("DJANGO_ENV_FILE", "server.env")
|
||||
monkeypatch.delenv("CUSTOM_SERVER_VALUE", raising=False)
|
||||
assert load_project_environment(Path(tmp_path)) == custom
|
||||
assert __import__("os").environ["CUSTOM_SERVER_VALUE"] == "yes"
|
||||
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
|
||||
from apps.core.templatetags.jalali import jalali_date, jalali_datetime, persian_time
|
||||
|
||||
|
||||
def test_jalali_datetime_uses_iran_time_persian_month_and_digits():
|
||||
value = datetime(2026, 9, 6, 5, 0, tzinfo=dt_timezone.utc)
|
||||
assert jalali_datetime(value) == "۱۵ شهریور ۱۴۰۵، ساعت ۰۸:۳۰"
|
||||
assert jalali_date(value) == "۱۵ شهریور ۱۴۰۵"
|
||||
assert persian_time(value) == "۰۸:۳۰"
|
||||
|
||||
|
||||
def test_jalali_filters_fail_safely_for_empty_values():
|
||||
assert jalali_datetime(None) == ""
|
||||
assert jalali_date(None) == ""
|
||||
assert persian_time(None) == ""
|
||||
@@ -0,0 +1,232 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User
|
||||
from apps.attachments.models import AttachmentPolicy
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import GroupDispatch, Ticket, TicketCategory, TicketTag
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.tickets.selectors import staff_ticket_rows
|
||||
from apps.tickets.services import create_ticket, dispatch_group_tickets, refresh_group_dispatch_status
|
||||
|
||||
|
||||
def make_staff(username, unit):
|
||||
user = User.objects.create_user(username=username, account_type=User.AccountType.STAFF)
|
||||
StaffProfile.objects.create(user=user, display_name=username, primary_unit=unit)
|
||||
return user
|
||||
|
||||
|
||||
def make_contact(username, mobile):
|
||||
user = User.objects.create_user(username=username, mobile=mobile, account_type=User.AccountType.CUSTOMER)
|
||||
CustomerProfile.objects.create(user=user, first_name=username)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unit_permission_isolates_ticket_queries_and_object_access():
|
||||
sales = OrganizationalUnit.objects.create(name="فروش", slug="sales")
|
||||
logistics = OrganizationalUnit.objects.create(name="بارگیری ایزوله", slug="isolated-logistics")
|
||||
view_permission = Permission.objects.get(codename="view_ticket", content_type__app_label="tickets")
|
||||
sales.permissions.add(view_permission)
|
||||
logistics.permissions.add(view_permission)
|
||||
sales_staff = make_staff("sales-agent", sales)
|
||||
logistics_staff = make_staff("logistics-agent", logistics)
|
||||
customer = make_contact("unit-customer", "09120000101")
|
||||
category = TicketCategory.objects.create(title="عمومی", slug="general")
|
||||
sales_ticket = create_ticket(customer=customer, created_by=sales_staff, unit=sales, category=category, subject="تیکت واحد فروش", body="شرح")
|
||||
|
||||
assert TicketPolicy.can_view(sales_staff, sales_ticket)
|
||||
assert not TicketPolicy.can_view(logistics_staff, sales_ticket)
|
||||
assert list(staff_ticket_rows(logistics_staff)) == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_group_dispatch_creates_one_isolated_ticket_and_sms_job_per_contact():
|
||||
unit = OrganizationalUnit.objects.create(name="لوجستیک ارسال گروهی", slug="dispatch-logistics")
|
||||
permission = Permission.objects.get(codename="dispatch_group_tickets", content_type__app_label="tickets")
|
||||
view_permission = Permission.objects.get(codename="view_ticket", content_type__app_label="tickets")
|
||||
unit.permissions.add(permission, view_permission)
|
||||
staff = make_staff("dispatcher", unit)
|
||||
first = make_contact("carrier-one", "09120000111")
|
||||
second = make_contact("carrier-two", "09120000112")
|
||||
group = ContactGroup.objects.create(name="باربریهای گلپایگان", unit=unit)
|
||||
first.customer_profile.groups.add(group)
|
||||
second.customer_profile.groups.add(group)
|
||||
category = TicketCategory.objects.create(unit=unit,title="استعلام حمل", slug="test-shipping-quote")
|
||||
tag = TicketTag.objects.create(name="استعلام قیمت", slug="price-inquiry")
|
||||
|
||||
dispatch, tickets = dispatch_group_tickets(user=staff, unit=unit, contact_group=group, category=category, subject="استعلام قیمت حمل", body="لطفاً قیمت پیشنهادی را ثبت کنید.", tags=[tag])
|
||||
|
||||
# Creation only queues delivery; reporting promotes it after SMS succeeds.
|
||||
assert dispatch.status == GroupDispatch.Status.QUEUED
|
||||
assert dispatch.recipient_count == 2
|
||||
assert len(tickets) == 2
|
||||
assert {ticket.customer_id for ticket in tickets} == {first.pk, second.pk}
|
||||
assert all(ticket.delivery_type == Ticket.DeliveryType.GROUP for ticket in tickets)
|
||||
assert NotificationJob.objects.filter(ticket__in=tickets, channel="SMS", event_type="STAFF_TICKET_CREATED").count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_group_dispatch_copies_shared_document_into_each_private_ticket():
|
||||
unit=OrganizationalUnit.objects.create(name="مالی گروهی",slug="bulk-finance")
|
||||
unit.permissions.add(Permission.objects.get(codename="dispatch_group_tickets",content_type__app_label="tickets"))
|
||||
staff=make_staff("bulk-finance-user",unit)
|
||||
first=make_contact("bulk-supplier-one","09120000311"); second=make_contact("bulk-supplier-two","09120000312")
|
||||
group=ContactGroup.objects.create(name="تامینکنندگان اسناد",unit=unit)
|
||||
first.customer_profile.groups.add(group); second.customer_profile.groups.add(group)
|
||||
category=TicketCategory.objects.create(title="سند گروهی",slug="bulk-document")
|
||||
AttachmentPolicy.objects.create(attachment_type="PDF",max_file_size=1024*1024,allowed_extensions=[".pdf"])
|
||||
upload=SimpleUploadedFile("receipt.pdf",b"%PDF-1.4\n%%EOF",content_type="application/pdf")
|
||||
|
||||
_,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="ارسال سند مالی مشترک",body="سند را بررسی کنید.",files=[upload])
|
||||
|
||||
assert len(tickets)==2
|
||||
assert all(ticket.ticket_messages.get().attachments.filter(original_name="receipt.pdf").count()==1 for ticket in tickets)
|
||||
paths=[ticket.ticket_messages.get().attachments.get().file.name for ticket in tickets]
|
||||
assert len(set(paths))==2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_magic_link_records_every_open_with_ip_and_user_agent(client):
|
||||
unit = OrganizationalUnit.objects.create(name="حسابداری ممیزی", slug="audit-accounting")
|
||||
staff = make_staff("accountant", unit)
|
||||
customer = make_contact("supplier", "09120000121")
|
||||
category = TicketCategory.objects.create(title="سند مالی", slug="financial-document")
|
||||
ticket = create_ticket(customer=customer, created_by=staff, unit=unit, category=category, subject="ارسال رسید مالی", body="رسید پیوست شد.")
|
||||
token, link = TicketMagicLink.issue_with_record(ticket, created_by=staff)
|
||||
|
||||
response = client.get(reverse("accounts:magic", args=[token]), HTTP_USER_AGENT="Audit Browser/1.0", REMOTE_ADDR="203.0.113.7")
|
||||
|
||||
assert response.status_code == 302
|
||||
link.refresh_from_db()
|
||||
assert link.view_count == 1
|
||||
assert link.last_used_ip == "203.0.113.7"
|
||||
assert link.last_user_agent == "Audit Browser/1.0"
|
||||
assert link.created_by == staff
|
||||
assert link.created_unit == unit
|
||||
assert MagicLinkAccessLog.objects.filter(magic_link=link, ip_address="203.0.113.7").count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dispatch_pages_are_available_only_to_the_dispatch_unit(client):
|
||||
unit = OrganizationalUnit.objects.create(name="انبار دسترسی", slug="access-warehouse")
|
||||
other_unit = OrganizationalUnit.objects.create(name="فروش", slug="sales-2")
|
||||
for codename in ("view_ticket", "dispatch_group_tickets"):
|
||||
permission = Permission.objects.get(codename=codename, content_type__app_label="tickets")
|
||||
unit.permissions.add(permission)
|
||||
staff = make_staff("warehouse-user", unit)
|
||||
outsider = make_staff("sales-user", other_unit)
|
||||
customer = make_contact("warehouse-contact", "09120000131")
|
||||
group = ContactGroup.objects.create(name="تامینکنندگان", unit=unit)
|
||||
customer.customer_profile.groups.add(group)
|
||||
category = TicketCategory.objects.create(title="موجودی", slug="inventory")
|
||||
|
||||
client.force_login(staff)
|
||||
assert client.get(reverse("staff_tickets:dispatch_create")).status_code == 200
|
||||
response = client.post(reverse("staff_tickets:dispatch_create"), {"unit": unit.pk, "contact_group": group.pk, "category": category.pk, "subject": "استعلام موجودی کالا", "body": "موجودی را اعلام کنید."})
|
||||
assert response.status_code == 302
|
||||
dispatch = GroupDispatch.objects.get()
|
||||
assert response.url == reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id])
|
||||
|
||||
client.force_login(outsider)
|
||||
assert client.get(reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id])).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_operations_pages_respect_unit_scoped_permissions(client):
|
||||
unit = OrganizationalUnit.objects.create(name="عملیات", slug="operations")
|
||||
other = OrganizationalUnit.objects.create(name="واحد محرمانه", slug="private-unit")
|
||||
codenames = [
|
||||
("customers", "view_customerprofile"), ("customers", "add_customerprofile"),
|
||||
("customers", "view_contactgroup"), ("customers", "add_contactgroup"),
|
||||
("accounts", "view_ticketmagiclink"), ("notifications", "view_smstemplate"),
|
||||
("notifications", "add_smstemplate"),
|
||||
]
|
||||
unit.permissions.add(*[Permission.objects.get(content_type__app_label=app, codename=code) for app, code in codenames])
|
||||
staff = make_staff("operations-agent", unit)
|
||||
visible = make_contact("visible-contact", "09120000201")
|
||||
hidden = make_contact("hidden-contact", "09120000202")
|
||||
visible_group = ContactGroup.objects.create(name="گروه مجاز", unit=unit)
|
||||
hidden_group = ContactGroup.objects.create(name="گروه محرمانه", unit=other)
|
||||
visible.customer_profile.groups.add(visible_group)
|
||||
hidden.customer_profile.groups.add(hidden_group)
|
||||
|
||||
client.force_login(staff)
|
||||
response = client.get(reverse("staff_tickets:contacts"))
|
||||
assert response.status_code == 200
|
||||
assert "visible-contact" in response.content.decode()
|
||||
assert "hidden-contact" not in response.content.decode()
|
||||
assert client.get(reverse("staff_tickets:groups")).status_code == 200
|
||||
assert client.get(reverse("staff_tickets:magic_audit")).status_code == 200
|
||||
assert client.get(reverse("staff_tickets:sms_templates")).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unit_scoped_add_ticket_preserves_contact_ownership_and_stakeholder(client):
|
||||
unit=OrganizationalUnit.objects.create(name="حسابداری عملیاتی",slug="accounting-ops")
|
||||
unit.permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="add_ticket"))
|
||||
staff=make_staff("unit-accountant",unit)
|
||||
group=ContactGroup.objects.create(name="تامینکنندگان مالی",unit=unit)
|
||||
category=TicketCategory.objects.create(title="رسید مالی",slug="receipt-ops")
|
||||
client.force_login(staff)
|
||||
|
||||
response=client.post(reverse("staff_tickets:create"),{
|
||||
"unit":unit.pk,"customer_mobile":"09120000301","first_name":"تامینکننده","last_name":"نمونه",
|
||||
"organization_name":"شرکت نمونه","stakeholder_type":CustomerProfile.StakeholderType.SUPPLIER,
|
||||
"groups":[group.pk],"category":category.pk,"subject":"ارسال رسید پرداخت","body":"رسید مالی پیوست خواهد شد.",
|
||||
})
|
||||
|
||||
assert response.status_code==302
|
||||
ticket=Ticket.objects.get(customer__mobile="+989120000301")
|
||||
assert ticket.owning_unit==unit and ticket.created_by==staff
|
||||
assert ticket.customer.customer_profile.stakeholder_type==CustomerProfile.StakeholderType.SUPPLIER
|
||||
assert ticket.customer.customer_profile.groups.filter(pk=group.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dispatch_retry_is_unit_scoped_and_refreshes_persistent_status(client):
|
||||
unit=OrganizationalUnit.objects.create(name="لوجستیک retry",slug="logistics-retry")
|
||||
other=OrganizationalUnit.objects.create(name="فروش retry",slug="sales-retry")
|
||||
permission=Permission.objects.get(content_type__app_label="tickets",codename="dispatch_group_tickets")
|
||||
unit.permissions.add(permission)
|
||||
staff=make_staff("retry-dispatcher",unit); outsider=make_staff("retry-outsider",other)
|
||||
contact=make_contact("retry-carrier","09120000302")
|
||||
group=ContactGroup.objects.create(name="باربری retry",unit=unit); contact.customer_profile.groups.add(group)
|
||||
category=TicketCategory.objects.create(title="استعلام retry",slug="quote-retry")
|
||||
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="استعلام هزینه جدید",body="قیمت را ثبت کنید.")
|
||||
job=NotificationJob.objects.get(ticket=tickets[0],channel="SMS")
|
||||
job.status=NotificationJob.Status.FAILED; job.attempts=3; job.last_error="provider"; job.save()
|
||||
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db()
|
||||
assert dispatch.status==GroupDispatch.Status.PARTIAL
|
||||
|
||||
client.force_login(outsider)
|
||||
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==403
|
||||
client.force_login(staff)
|
||||
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==302
|
||||
job.refresh_from_db(); dispatch.refresh_from_db()
|
||||
assert (job.status,job.attempts,job.last_error)==(NotificationJob.Status.PENDING,0,"")
|
||||
assert dispatch.status==GroupDispatch.Status.QUEUED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_magic_link_can_be_revoked_only_with_unit_permission(client):
|
||||
unit=OrganizationalUnit.objects.create(name="حسابرسی",slug="audit-unit")
|
||||
other=OrganizationalUnit.objects.create(name="واحد دیگر audit",slug="audit-other")
|
||||
view=Permission.objects.get(content_type__app_label="accounts",codename="view_ticketmagiclink")
|
||||
change=Permission.objects.get(content_type__app_label="accounts",codename="change_ticketmagiclink")
|
||||
unit.permissions.add(view,change)
|
||||
auditor=make_staff("unit-auditor",unit); outsider=make_staff("audit-outsider",other)
|
||||
customer=make_contact("audit-contact","09120000303")
|
||||
category=TicketCategory.objects.create(title="ممیزی",slug="audit-revoke")
|
||||
ticket=create_ticket(customer=customer,created_by=auditor,unit=unit,category=category,subject="سند قابل ممیزی",body="سند")
|
||||
_,link=TicketMagicLink.issue_with_record(ticket,created_by=auditor)
|
||||
|
||||
client.force_login(outsider)
|
||||
assert client.post(reverse("staff_tickets:magic_revoke",args=[link.pk])).status_code==403
|
||||
client.force_login(auditor)
|
||||
assert client.post(reverse("staff_tickets:magic_revoke",args=[link.pk])).status_code==302
|
||||
link.refresh_from_db(); assert link.revoked_at is not None
|
||||
@@ -0,0 +1,233 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile, UnitMembership
|
||||
from apps.tickets.forms import StaffCreateTicketForm
|
||||
from apps.tickets.models import GroupDispatch, Ticket, TicketCategory
|
||||
from apps.tickets.policies import DispatchPolicy, TicketPolicy
|
||||
from apps.tickets.selectors import staff_ticket_rows
|
||||
from apps.tickets.services import create_ticket, dispatch_group_tickets
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def organization():
|
||||
call_command("bootstrap")
|
||||
return {
|
||||
"sales": OrganizationalUnit.objects.get(slug="sales"),
|
||||
"warehouse": OrganizationalUnit.objects.get(slug="warehouse-loading"),
|
||||
"finance": OrganizationalUnit.objects.get(slug="finance"),
|
||||
"executive": OrganizationalUnit.objects.get(slug="executive"),
|
||||
}
|
||||
|
||||
|
||||
def customer(username, mobile):
|
||||
user = User.objects.create_user(username=username, mobile=mobile, account_type=User.AccountType.CUSTOMER)
|
||||
CustomerProfile.objects.create(user=user, first_name=username)
|
||||
return user
|
||||
|
||||
|
||||
def add_contact(unit, username, mobile, group_name):
|
||||
user = customer(username, mobile)
|
||||
group = ContactGroup.objects.create(unit=unit, name=group_name)
|
||||
user.customer_profile.groups.add(group)
|
||||
return user, group
|
||||
|
||||
|
||||
def test_bootstrap_creates_requested_users_roles_units_and_explicit_permissions(organization):
|
||||
assert not OrganizationalUnit.objects.filter(slug="accounting").exists()
|
||||
assert not OrganizationalUnit.objects.filter(slug__in=("logistics", "warehouse")).exists()
|
||||
expected = {
|
||||
"kimiya_ghaffari": ("sales", StaffProfile.Role.SALES_EXPERT, UnitMembership.TicketScope.SELF),
|
||||
"zahra_jamali": ("sales", StaffProfile.Role.SALES_EXPERT, UnitMembership.TicketScope.SELF),
|
||||
"maryam_fotovat": ("sales", StaffProfile.Role.SALES_SUPERVISOR, UnitMembership.TicketScope.UNIT),
|
||||
"morteza_sokhaeifard": ("sales", StaffProfile.Role.SALES_MANAGER, UnitMembership.TicketScope.UNIT),
|
||||
"mostafa_sokhaeifard": ("executive", StaffProfile.Role.CEO, UnitMembership.TicketScope.UNIT),
|
||||
"ms_sabzali": ("warehouse-loading", StaffProfile.Role.WAREHOUSE_EXPERT, UnitMembership.TicketScope.UNIT),
|
||||
"mehdi_azami": ("warehouse-loading", StaffProfile.Role.WAREHOUSE_EXPERT, UnitMembership.TicketScope.UNIT),
|
||||
"fereshteh_yavari": ("finance", StaffProfile.Role.FINANCE_EXPERT, UnitMembership.TicketScope.UNIT),
|
||||
"fatemeh_sokhaei": ("finance", StaffProfile.Role.FINANCE_EXPERT, UnitMembership.TicketScope.UNIT),
|
||||
"rana_nobakht": ("finance", StaffProfile.Role.FINANCE_EXPERT, UnitMembership.TicketScope.UNIT),
|
||||
"mehdi_liaqat": ("finance", StaffProfile.Role.FINANCE_EXPERT, UnitMembership.TicketScope.UNIT),
|
||||
"aliasghar_sokhaeifard": ("finance", StaffProfile.Role.FINANCE_EXPERT, UnitMembership.TicketScope.UNIT),
|
||||
}
|
||||
for username, (unit_slug, role, scope) in expected.items():
|
||||
user = User.objects.get(username=username)
|
||||
assert user.staff_profile.role == role
|
||||
assert user.staff_profile.primary_unit.slug == unit_slug
|
||||
assert list(user.unit_memberships.values_list("unit__slug", flat=True)) == [unit_slug]
|
||||
assert user.unit_memberships.get().ticket_scope == scope
|
||||
sabzali = User.objects.get(username="ms_sabzali")
|
||||
assert DispatchPolicy.can_create(sabzali, organization["warehouse"], GroupDispatch.OperationType.BULK)
|
||||
assert DispatchPolicy.can_create(sabzali, organization["warehouse"], GroupDispatch.OperationType.INQUIRY)
|
||||
assert not DispatchPolicy.can_view_all(sabzali, GroupDispatch.OperationType.BULK)
|
||||
ceo = User.objects.get(username="mostafa_sokhaeifard")
|
||||
assert ceo.has_perm("tickets.view_all_tickets")
|
||||
assert DispatchPolicy.can_view_all(ceo, GroupDispatch.OperationType.BULK)
|
||||
assert DispatchPolicy.can_view_all(ceo, GroupDispatch.OperationType.INQUIRY)
|
||||
|
||||
|
||||
def test_bootstrap_merges_legacy_accounting_into_canonical_finance(client, organization):
|
||||
finance = organization["finance"]
|
||||
legacy = OrganizationalUnit.objects.create(
|
||||
slug="accounting", name="حسابداری", is_active=True,
|
||||
accepts_external_tickets=True, show_in_customer_form=True,
|
||||
)
|
||||
legacy_category = TicketCategory.objects.create(
|
||||
unit=legacy, title="اسناد حسابداری قدیمی", slug="legacy-accounting-documents",
|
||||
)
|
||||
legacy_group = ContactGroup.objects.create(unit=legacy, name="مخاطبان حسابداری قدیمی")
|
||||
legacy_user = User.objects.create_user(
|
||||
username="legacy-accountant", account_type=User.AccountType.STAFF,
|
||||
)
|
||||
StaffProfile.objects.create(
|
||||
user=legacy_user, primary_unit=legacy, role=StaffProfile.Role.FINANCE_EXPERT,
|
||||
display_name="کارشناس حسابداری قدیمی", is_customer_selectable=True,
|
||||
)
|
||||
|
||||
call_command("bootstrap")
|
||||
|
||||
assert not OrganizationalUnit.objects.filter(slug="accounting").exists()
|
||||
legacy_category.refresh_from_db()
|
||||
legacy_group.refresh_from_db()
|
||||
legacy_user.staff_profile.refresh_from_db()
|
||||
assert legacy_category.unit == finance
|
||||
assert legacy_group.unit == finance
|
||||
assert legacy_user.staff_profile.primary_unit == finance
|
||||
assert list(legacy_user.unit_memberships.values_list("unit", flat=True)) == [finance.pk]
|
||||
buyer = customer("legacy-merge-buyer", "09120007109")
|
||||
client.force_login(buyer)
|
||||
response = client.get(reverse("tickets:new_options"), {"unit": finance.pk})
|
||||
assert response["Cache-Control"] == "private, no-cache, no-store, must-revalidate"
|
||||
assert "کارشناس حسابداری قدیمی" in {item["name"] for item in response.json()["experts"]}
|
||||
|
||||
|
||||
def test_ticket_visibility_obeys_self_unit_and_global_scopes(organization):
|
||||
sales, warehouse, finance = organization["sales"], organization["warehouse"], organization["finance"]
|
||||
kimia = User.objects.get(username="kimiya_ghaffari")
|
||||
zahra = User.objects.get(username="zahra_jamali")
|
||||
supervisor = User.objects.get(username="maryam_fotovat")
|
||||
loader = User.objects.get(username="ms_sabzali")
|
||||
accountant = User.objects.get(username="fereshteh_yavari")
|
||||
ceo = User.objects.get(username="mostafa_sokhaeifard")
|
||||
buyer = customer("permission-buyer", "09120007101")
|
||||
sales_category = TicketCategory.objects.get(slug="sales-general")
|
||||
warehouse_category = TicketCategory.objects.get(slug="warehouse-loading-general")
|
||||
finance_category = TicketCategory.objects.get(slug="finance-general")
|
||||
public_sales = create_ticket(customer=buyer, unit=sales, category=sales_category, subject="درخواست عمومی فروش", body="شرح عمومی")
|
||||
selected_sales = create_ticket(customer=buyer, unit=sales, category=sales_category, subject="درخواست انتخابی زهرا", body="شرح", selected_expert=zahra)
|
||||
kimia_owned = create_ticket(customer=buyer, created_by=kimia, unit=sales, category=sales_category, subject="ایجادشده توسط کیمیا", body="شرح")
|
||||
warehouse_ticket = create_ticket(customer=buyer, created_by=loader, unit=warehouse, category=warehouse_category, subject="درخواست داخلی بارگیری", body="شرح")
|
||||
finance_ticket = create_ticket(customer=buyer, unit=finance, category=finance_category, subject="درخواست مالی", body="شرح")
|
||||
|
||||
assert set(staff_ticket_rows(kimia)) == {public_sales, kimia_owned}
|
||||
assert TicketPolicy.can_reply(kimia, public_sales)
|
||||
assert not TicketPolicy.can_view(kimia, selected_sales)
|
||||
assert {public_sales, selected_sales, kimia_owned}.issubset(set(staff_ticket_rows(supervisor)))
|
||||
assert set(staff_ticket_rows(loader)) == {warehouse_ticket}
|
||||
assert set(staff_ticket_rows(accountant)) == {finance_ticket}
|
||||
assert set(staff_ticket_rows(ceo)) == {public_sales, selected_sales, kimia_owned, warehouse_ticket, finance_ticket}
|
||||
|
||||
|
||||
def test_staff_ticket_form_locks_primary_unit_and_rejects_tampered_unit_category_and_group(client, organization):
|
||||
kimia = User.objects.get(username="kimiya_ghaffari")
|
||||
sales, finance = organization["sales"], organization["finance"]
|
||||
sales_category = TicketCategory.objects.get(slug="sales-general")
|
||||
finance_category = TicketCategory.objects.get(slug="finance-general")
|
||||
sales_group = ContactGroup.objects.get(unit=sales, name__startswith="مخاطبان")
|
||||
finance_group = ContactGroup.objects.get(unit=finance, name__startswith="مخاطبان")
|
||||
form = StaffCreateTicketForm(user=kimia)
|
||||
assert list(form.fields["unit"].queryset) == [sales]
|
||||
assert sales_category in form.fields["category"].queryset
|
||||
assert not form.fields["category"].queryset.exclude(unit=sales).exists()
|
||||
assert sales_group in form.fields["groups"].queryset and finance_group not in form.fields["groups"].queryset
|
||||
client.force_login(kimia)
|
||||
response = client.get(reverse("staff_tickets:create"))
|
||||
assert response.status_code == 200
|
||||
assert b'type="hidden" name="unit"' in response.content
|
||||
before = Ticket.objects.count()
|
||||
response = client.post(reverse("staff_tickets:create"), {
|
||||
"unit": finance.pk, "customer_mobile": "09120007102", "first_name": "مشتری", "last_name": "آزمایشی",
|
||||
"groups": [finance_group.pk], "category": finance_category.pk, "subject": "درخواست دستکاری شده", "body": "نباید ثبت شود",
|
||||
})
|
||||
assert response.status_code == 200 and Ticket.objects.count() == before
|
||||
|
||||
|
||||
def test_contact_create_only_accepts_groups_from_users_unit(client, organization):
|
||||
kimia = User.objects.get(username="kimiya_ghaffari")
|
||||
sales_group = ContactGroup.objects.get(unit=organization["sales"], name__startswith="مخاطبان")
|
||||
finance_group = ContactGroup.objects.get(unit=organization["finance"], name__startswith="مخاطبان")
|
||||
client.force_login(kimia)
|
||||
page = client.get(reverse("staff_tickets:contact_create"))
|
||||
assert f'value="{sales_group.pk}"'.encode() in page.content
|
||||
assert f'value="{finance_group.pk}"'.encode() not in page.content
|
||||
response = client.post(reverse("staff_tickets:contact_create"), {
|
||||
"mobile": "09120007103", "first_name": "مخاطب", "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER,
|
||||
"groups": [finance_group.pk],
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert not User.objects.filter(mobile="+989120007103").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation_type", [GroupDispatch.OperationType.BULK, GroupDispatch.OperationType.INQUIRY])
|
||||
def test_dispatch_and_inquiry_limited_owner_and_global_view_all(client, organization, operation_type):
|
||||
warehouse = organization["warehouse"]
|
||||
category = TicketCategory.objects.get(slug="warehouse-loading-general")
|
||||
_, group = add_contact(warehouse, f"recipient-{operation_type.lower()}", "09120007104" if operation_type == "BULK" else "09120007105", f"گروه {operation_type}")
|
||||
sabzali = User.objects.get(username="ms_sabzali")
|
||||
other = User.objects.get(username="mehdi_azami")
|
||||
permission = Permission.objects.get(content_type__app_label="tickets", codename="create_bulk_dispatch" if operation_type == "BULK" else "create_inquiry")
|
||||
other.unit_memberships.get(unit=warehouse).permissions.add(permission)
|
||||
own, _ = dispatch_group_tickets(user=sabzali, unit=warehouse, contact_group=group, category=category, operation_type=operation_type, subject=f"عملیات خود {operation_type}", body="متن عملیات")
|
||||
other_item, _ = dispatch_group_tickets(user=other, unit=warehouse, contact_group=group, category=category, operation_type=operation_type, subject=f"عملیات دیگر {operation_type}", body="متن عملیات")
|
||||
client.force_login(sabzali)
|
||||
listing = client.get(reverse("staff_tickets:dispatch_create"))
|
||||
assert own.subject in listing.content.decode() and other_item.subject not in listing.content.decode()
|
||||
assert client.get(reverse("staff_tickets:dispatch_detail", args=[other_item.public_id])).status_code == 403
|
||||
client.force_login(User.objects.get(username="mostafa_sokhaeifard"))
|
||||
listing = client.get(reverse("staff_tickets:dispatch_create"))
|
||||
assert own.subject in listing.content.decode() and other_item.subject in listing.content.decode()
|
||||
assert client.get(reverse("staff_tickets:dispatch_detail", args=[other_item.public_id])).status_code == 200
|
||||
|
||||
|
||||
def test_user_without_operation_permission_is_denied(client, organization):
|
||||
user = User.objects.get(username="mehdi_azami")
|
||||
client.force_login(user)
|
||||
assert client.get(reverse("staff_tickets:dispatch_create")).status_code == 403
|
||||
|
||||
|
||||
def test_customer_options_and_search_are_unit_scoped_and_backend_rejects_cross_unit_expert(client, organization):
|
||||
sales, finance = organization["sales"], organization["finance"]
|
||||
buyer = customer("dynamic-customer", "09120007106")
|
||||
client.force_login(buyer)
|
||||
sales_payload = client.get(reverse("tickets:new_options"), {"unit": sales.pk}).json()
|
||||
finance_payload = client.get(reverse("tickets:new_options"), {"unit": finance.pk}).json()
|
||||
assert sales_payload["queue"] == {"unit_id": sales.pk, "label": "صف عمومی فروش"}
|
||||
assert finance_payload["queue"]["unit_id"] == finance.pk
|
||||
assert {item["name"] for item in sales_payload["experts"]}.issuperset({"کیمیا غفاری", "زهرا جمالی"})
|
||||
assert not ({item["name"] for item in sales_payload["experts"]} & {item["name"] for item in finance_payload["experts"]})
|
||||
search = client.get(reverse("tickets:new_search"), {"q": "مالی و حسابداری"}).json()["results"]
|
||||
assert search and search[0]["unit_id"] == finance.pk
|
||||
finance_expert = User.objects.get(username="fereshteh_yavari").staff_profile
|
||||
response = client.post(reverse("tickets:new"), {
|
||||
"unit": sales.pk, "category": TicketCategory.objects.get(slug="sales-general").pk,
|
||||
"selected_expert": finance_expert.pk, "subject": "انتخاب کارشناس نامرتبط", "body": "این درخواست باید رد شود",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert not Ticket.objects.filter(subject="انتخاب کارشناس نامرتبط").exists()
|
||||
|
||||
|
||||
def test_customer_new_initial_queue_uses_the_default_unit(client, organization):
|
||||
buyer = customer("default-unit-customer", "09120007110")
|
||||
client.force_login(buyer)
|
||||
response = client.get(reverse("tickets:new"))
|
||||
assert response.status_code == 200
|
||||
assert response.context["selected_unit"] == organization["sales"]
|
||||
assert f'data-unit-id="{organization["sales"].pk}"'.encode() in response.content
|
||||
assert "صف عمومی فروش" in response.content.decode()
|
||||
@@ -0,0 +1,43 @@
|
||||
import pytest
|
||||
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.messaging.models import Message, MessageRevision
|
||||
from apps.staff.models import StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
|
||||
|
||||
def test_normalizer_corrects_persian_letters_and_preserves_paragraphs_and_codes():
|
||||
source = "علي مي رود AB-123 و https://example.com/a?q=1\n\nاين يك تست است."
|
||||
assert normalize_persian_text(source, multiline=True) == (
|
||||
"علی میرود AB-123 و https://example.com/a?q=1\n\nاین یک تست است."
|
||||
)
|
||||
|
||||
|
||||
def test_normalizer_does_not_convert_numbers_or_remove_marks():
|
||||
assert normalize_persian_text("کد 123، قیمت 12.5% 😊") == "کد 123، قیمت 12.5% 😊"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_selected_profile_fields_are_normalized_on_save():
|
||||
customer = User.objects.create_user(username="persian-customer", mobile="09121112222")
|
||||
staff_user = User.objects.create_user(username="persian-staff", mobile="09121113333", account_type="STAFF")
|
||||
profile = CustomerProfile.objects.create(user=customer, first_name="علي", last_name="رضايي")
|
||||
staff = StaffProfile.objects.create(user=staff_user, display_name="كارشناس فروش")
|
||||
profile.refresh_from_db()
|
||||
staff.refresh_from_db()
|
||||
assert (profile.first_name, profile.last_name) == ("علی", "رضایی")
|
||||
assert staff.display_name == "کارشناس فروش"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_message_and_revision_are_normalized_on_save():
|
||||
customer = User.objects.create_user(username="message-customer", mobile="09121114444")
|
||||
category = TicketCategory.objects.create(title="پشتيباني", slug="persian-normalization")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="موضوع معتبر", body="متن آغازين")
|
||||
message = Message.objects.create(ticket=ticket, author=customer, body="اين متن است")
|
||||
revision = MessageRevision.objects.create(message=message, body="متن قبلي", edited_by=customer)
|
||||
assert message.body == "این متن است"
|
||||
assert revision.body == "متن قبلی"
|
||||
@@ -0,0 +1,194 @@
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile, UnitMembership
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.tickets.services import create_ticket
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qa_organization():
|
||||
call_command("bootstrap")
|
||||
return {
|
||||
slug: OrganizationalUnit.objects.get(slug=slug)
|
||||
for slug in ("sales", "warehouse-loading", "finance", "executive")
|
||||
}
|
||||
|
||||
|
||||
REPRESENTATIVES = [
|
||||
("kimiya_ghaffari", "sales", StaffProfile.Role.SALES_EXPERT, UnitMembership.TicketScope.SELF),
|
||||
("maryam_fotovat", "sales", StaffProfile.Role.SALES_SUPERVISOR, UnitMembership.TicketScope.UNIT),
|
||||
("morteza_sokhaeifard", "sales", StaffProfile.Role.SALES_MANAGER, UnitMembership.TicketScope.UNIT),
|
||||
("ms_sabzali", "warehouse-loading", StaffProfile.Role.WAREHOUSE_EXPERT, UnitMembership.TicketScope.UNIT),
|
||||
("fereshteh_yavari", "finance", StaffProfile.Role.FINANCE_EXPERT, UnitMembership.TicketScope.UNIT),
|
||||
("mostafa_sokhaeifard", "executive", StaffProfile.Role.CEO, UnitMembership.TicketScope.UNIT),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("username,unit_slug,role,scope", REPRESENTATIVES)
|
||||
def test_representative_role_unit_scope_and_primary_navigation(
|
||||
client, qa_organization, username, unit_slug, role, scope
|
||||
):
|
||||
user = User.objects.get(username=username)
|
||||
membership = user.unit_memberships.get()
|
||||
assert user.staff_profile.role == role
|
||||
assert user.staff_profile.primary_unit == qa_organization[unit_slug]
|
||||
assert membership.unit == qa_organization[unit_slug]
|
||||
assert membership.ticket_scope == scope
|
||||
assert membership.is_primary and membership.is_active
|
||||
client.force_login(user)
|
||||
dashboard = client.get(reverse("staff_tickets:list"))
|
||||
assert dashboard.status_code == 200
|
||||
assert reverse("staff_tickets:create") in dashboard.content.decode()
|
||||
assert client.get(reverse("staff_tickets:create")).status_code == 200
|
||||
|
||||
|
||||
def test_sales_expert_can_open_and_reply_to_public_own_and_assigned_tickets(client, qa_organization):
|
||||
sales = qa_organization["sales"]
|
||||
category = TicketCategory.objects.get(slug="sales-general")
|
||||
kimia = User.objects.get(username="kimiya_ghaffari")
|
||||
zahra = User.objects.get(username="zahra_jamali")
|
||||
customer = User.objects.create_user(
|
||||
username="qa-sales-customer", mobile="09120008401", account_type=User.AccountType.CUSTOMER
|
||||
)
|
||||
CustomerProfile.objects.create(user=customer, first_name="مشتری")
|
||||
public = create_ticket(customer=customer, unit=sales, category=category, subject="عمومی QA", body="شرح")
|
||||
own = create_ticket(
|
||||
customer=customer, created_by=kimia, unit=sales, category=category, subject="ساخته کیمیا QA", body="شرح"
|
||||
)
|
||||
assigned = create_ticket(
|
||||
customer=customer, unit=sales, category=category, selected_expert=kimia,
|
||||
subject="اختصاص کیمیا QA", body="شرح",
|
||||
)
|
||||
unrelated = create_ticket(
|
||||
customer=customer, unit=sales, category=category, selected_expert=zahra,
|
||||
subject="اختصاص زهرا QA", body="شرح",
|
||||
)
|
||||
client.force_login(kimia)
|
||||
for ticket in (public, own, assigned):
|
||||
assert client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).status_code == 200
|
||||
response = client.post(
|
||||
reverse("staff_tickets:reply", args=[ticket.public_id]), {"body": f"پاسخ مجاز {ticket.pk}"}
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert client.get(reverse("staff_tickets:detail", args=[unrelated.public_id])).status_code == 403
|
||||
assert not TicketPolicy.can_reply(kimia, unrelated)
|
||||
|
||||
|
||||
def test_staff_ticket_creation_completes_with_unit_category_customer_group_and_message(client, qa_organization):
|
||||
sales = qa_organization["sales"]
|
||||
kimia = User.objects.get(username="kimiya_ghaffari")
|
||||
category = TicketCategory.objects.get(slug="sales-general")
|
||||
group = ContactGroup.objects.get(unit=sales, name__startswith="مخاطبان")
|
||||
client.force_login(kimia)
|
||||
response = client.post(
|
||||
reverse("staff_tickets:create"),
|
||||
{
|
||||
"unit": sales.pk,
|
||||
"customer_mobile": "09120008402",
|
||||
"first_name": "مشتری",
|
||||
"last_name": "فروش",
|
||||
"stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER,
|
||||
"groups": [group.pk],
|
||||
"category": category.pk,
|
||||
"subject": "ایجاد کامل Staff QA",
|
||||
"body": "پیام اولیه کامل کارشناس",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
ticket = Ticket.objects.get(subject="ایجاد کامل Staff QA")
|
||||
assert ticket.owning_unit == sales and ticket.assigned_unit == sales
|
||||
assert ticket.category == category and ticket.created_by == kimia and ticket.owner == kimia
|
||||
assert ticket.ticket_messages.get().body == "پیام اولیه کامل کارشناس"
|
||||
assert group in ticket.customer.customer_profile.groups.all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unit_slug,mobile", [("sales", "09120008411"), ("finance", "09120008413")]
|
||||
)
|
||||
def test_public_options_exactly_match_each_units_staff_categories_and_queue(
|
||||
client, qa_organization, unit_slug, mobile
|
||||
):
|
||||
customer = User.objects.create_user(
|
||||
username=f"qa-options-{unit_slug}", mobile=mobile,
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
CustomerProfile.objects.create(user=customer, first_name="مشتری")
|
||||
client.force_login(customer)
|
||||
unit = qa_organization[unit_slug]
|
||||
payload = client.get(reverse("tickets:new_options"), {"unit": unit.pk}).json()
|
||||
expected_experts = set(
|
||||
StaffProfile.objects.filter(primary_unit=unit, is_active=True, is_customer_selectable=True)
|
||||
.values_list("display_name", flat=True)
|
||||
)
|
||||
expected_categories = set(
|
||||
TicketCategory.objects.filter(unit=unit, is_active=True, show_in_customer_form=True)
|
||||
.values_list("title", flat=True)
|
||||
)
|
||||
assert {row["name"] for row in payload["experts"]} == expected_experts
|
||||
assert {row["label"] for row in payload["categories"]} == expected_categories
|
||||
assert payload["queue"] == {"unit_id": unit.pk, "label": f"صف عمومی {unit.name}"}
|
||||
|
||||
|
||||
def test_invalid_public_attachment_is_a_form_error_and_rolls_back_ticket(client, qa_organization, tmp_path, settings):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
customer = User.objects.create_user(
|
||||
username="qa-invalid-attachment", mobile="09120008403", account_type=User.AccountType.CUSTOMER
|
||||
)
|
||||
CustomerProfile.objects.create(user=customer, first_name="مشتری")
|
||||
client.force_login(customer)
|
||||
sales = qa_organization["sales"]
|
||||
before = Ticket.objects.count()
|
||||
response = client.post(
|
||||
reverse("tickets:new"),
|
||||
{
|
||||
"unit": sales.pk,
|
||||
"category": TicketCategory.objects.get(slug="sales-general").pk,
|
||||
"subject": "فایل غیرمجاز QA",
|
||||
"body": "فایل اجرایی نباید ذخیره شود",
|
||||
"files": SimpleUploadedFile("malware.exe", b"MZ-not-an-executable", content_type="application/octet-stream"),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "مجاز نیست" in response.content.decode()
|
||||
assert Ticket.objects.count() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"missing_field,payload_update,error_key",
|
||||
[
|
||||
("unit", {"unit": ""}, "unit"),
|
||||
("category", {"category": ""}, "category"),
|
||||
("subject", {"subject": ""}, "subject"),
|
||||
("message", {"body": ""}, "__all__"),
|
||||
],
|
||||
)
|
||||
def test_public_ticket_required_field_validation_is_repeatable(
|
||||
client, qa_organization, missing_field, payload_update, error_key
|
||||
):
|
||||
customer = User.objects.create_user(
|
||||
username=f"qa-validation-{missing_field}", mobile=f"0912000850{len(missing_field)}",
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
CustomerProfile.objects.create(user=customer, first_name="مشتری")
|
||||
client.force_login(customer)
|
||||
sales = qa_organization["sales"]
|
||||
payload = {
|
||||
"unit": sales.pk,
|
||||
"category": TicketCategory.objects.get(slug="sales-general").pk,
|
||||
"subject": "اعتبارسنجی عادی QA",
|
||||
"body": "شرح معتبر",
|
||||
}
|
||||
payload.update(payload_update)
|
||||
response = client.post(reverse("tickets:new"), payload)
|
||||
assert response.status_code == 200
|
||||
assert error_key in response.context["form"].errors
|
||||
assert not Ticket.objects.filter(subject="اعتبارسنجی عادی QA").exists()
|
||||
@@ -0,0 +1,545 @@
|
||||
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 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):
|
||||
category = TicketCategory.objects.create(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, display_name="کارشناس اول")
|
||||
StaffProfile.objects.create(user=observer, 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("موضوع قابل مشاهده") 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):
|
||||
category=TicketCategory.objects.create(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,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-voice-recorder" in content
|
||||
assert "data-file-preview" in content
|
||||
assert reverse("tickets:expert_avatar", args=[expert.public_id]) in content
|
||||
|
||||
@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_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
|
||||
|
||||
@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):
|
||||
category=TicketCategory.objects.create(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,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"):
|
||||
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
|
||||
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_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
|
||||
Reference in new issue
Block a user