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)