This commit is contained in:
javad committed 2026-09-10 11:34:19 +03:30
commit fa9d154db9
260 files changed
+17643

No files matched your search

+111
View File
@@ -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.
+36
View File
@@ -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")),
)
+77
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Reusable page objects for browser-level tests."""
+20
View File
@@ -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")))
+13
View File
@@ -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")
+31
View File
@@ -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"))
+120
View File
@@ -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)
+78
View File
@@ -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"],
)
+42
View File
@@ -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"
+88
View File
@@ -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)
+67
View File
@@ -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);
});
+149
View File
@@ -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()
+78
View File
@@ -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
+162
View File
@@ -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 == "ترتیب نمایش در انتخاب کارشناس"
+52
View File
@@ -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)
+392
View File
@@ -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"]=="پیام آزمایشی"
+36
View File
@@ -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"
+16
View File
@@ -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) == ""
+232
View File
@@ -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
+233
View File
@@ -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()
+43
View File
@@ -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 == "متن قبلی"
+194
View File
@@ -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()
+545
View File
@@ -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