first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
@@ -0,0 +1,111 @@
|
||||
# End-to-end browser tests
|
||||
|
||||
The E2E suite uses Python Playwright, Chromium, `pytest-django`, and Django's
|
||||
transactional `live_server`. It is intentionally separate from fast unit and view tests.
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
tests/e2e/
|
||||
├── config.py # environment-driven browser settings
|
||||
├── conftest.py # browser lifecycle, isolated contexts, failure artifacts
|
||||
├── pages/ # reusable Page Objects and stable UI actions
|
||||
│ ├── base.py
|
||||
│ ├── auth.py
|
||||
│ └── tickets.py
|
||||
├── test_chat_coach.py # user-visible workflow examples
|
||||
└── test_pwa_versioning.py
|
||||
```
|
||||
|
||||
Keep business setup in the test/database layer and browser interactions in Page Objects.
|
||||
Tests should describe user outcomes, not repeat selectors or login steps.
|
||||
|
||||
## Installation
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pip install -r requirements-dev.txt
|
||||
.\.venv\Scripts\python.exe -m playwright install chromium
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```powershell
|
||||
# All browser tests
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e -m e2e -q
|
||||
|
||||
# One scenario
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e\test_chat_coach.py::test_customer_sees_chat_guidance_after_two_rapid_messages -q
|
||||
|
||||
# Only service-worker/PWA scenarios
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e -m pwa -q
|
||||
|
||||
# Visible browser with slowed interactions for debugging
|
||||
$env:E2E_HEADLESS="false"
|
||||
$env:E2E_SLOW_MO="250"
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e -m e2e -s
|
||||
```
|
||||
|
||||
Supported settings are `E2E_HEADLESS`, `E2E_SLOW_MO`, `E2E_ACTION_TIMEOUT_MS`,
|
||||
`E2E_NAVIGATION_TIMEOUT_MS`, `E2E_VIEWPORT_WIDTH`, `E2E_VIEWPORT_HEIGHT`, and
|
||||
`E2E_ARTIFACT_DIR`.
|
||||
|
||||
### QA / staging / live smoke checks
|
||||
|
||||
The destructive workflow suite always uses Django's isolated `live_server`. A separate,
|
||||
read-only smoke layer can target a deployed environment without modifying real tickets:
|
||||
|
||||
```powershell
|
||||
$env:E2E_BASE_URL="https://qa.example.com"
|
||||
$env:E2E_ENVIRONMENT="qa"
|
||||
$env:E2E_CUSTOMER_MOBILE="0912..."
|
||||
$env:E2E_CUSTOMER_PASSWORD="..."
|
||||
$env:E2E_STAFF_USERNAME="qa-staff"
|
||||
$env:E2E_STAFF_PASSWORD="..."
|
||||
.\.venv\Scripts\python.exe -m pytest tests\e2e\test_external_environment_smoke.py -m external_e2e -q
|
||||
```
|
||||
|
||||
`BASE_URL` is accepted as an alias for `E2E_BASE_URL`. Missing URLs or credentials are
|
||||
reported as skipped tests, never as a successful external run. Use only controlled accounts.
|
||||
|
||||
## Failure diagnostics
|
||||
|
||||
Every test receives a fresh Chromium process and BrowserContext. This keeps Django's synchronous
|
||||
database lifecycle isolated from Playwright's event loop and prevents cross-test browser state.
|
||||
On failure, the fixture automatically saves these files under `test-results/e2e/<test-name>/`:
|
||||
|
||||
- `failure.png`: full-page screenshot at failure time;
|
||||
- `trace.zip`: DOM snapshots, screenshots, network timing and browser actions.
|
||||
|
||||
Open a trace with:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m playwright show-trace test-results\e2e\<test-name>\trace.zip
|
||||
```
|
||||
|
||||
JavaScript page errors also fail the test and produce the same artifacts. Ordinary UI tests
|
||||
block service workers. A PWA test must use `browser_page(service_workers="allow")` and the
|
||||
`pwa` marker.
|
||||
|
||||
## Adding a scenario
|
||||
|
||||
1. Mark the module with `e2e` and `django_db(transaction=True)`.
|
||||
2. Create only the minimum database state required by the scenario.
|
||||
3. Open an isolated page using `with browser_page() as page:`.
|
||||
4. Use an existing Page Object; add reusable selectors/actions there if needed.
|
||||
5. Assert a user-visible outcome with Playwright's auto-waiting `expect` API.
|
||||
6. Avoid `sleep`; wait on URLs, elements, responses, or application state.
|
||||
|
||||
Minimal pattern:
|
||||
|
||||
```python
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
def test_example(live_server, browser_page, customer):
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
tickets = TicketConversationPage(page, live_server.url)
|
||||
tickets.goto("/tickets/")
|
||||
expect(page.get_by_role("heading", name="درخواستهای من")).to_be_visible()
|
||||
```
|
||||
|
||||
The fixture prefers Playwright's bundled Chromium and falls back to installed Google Chrome.
|
||||
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _boolean(name, default):
|
||||
value = os.getenv(name)
|
||||
return default if value is None else value.strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class E2ESettings:
|
||||
base_url: str | None
|
||||
environment: str
|
||||
headless: bool
|
||||
slow_mo: int
|
||||
action_timeout_ms: int
|
||||
navigation_timeout_ms: int
|
||||
viewport: dict
|
||||
artifact_dir: Path
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls):
|
||||
return cls(
|
||||
base_url=(os.getenv("E2E_BASE_URL") or os.getenv("BASE_URL") or "").rstrip("/") or None,
|
||||
environment=os.getenv("E2E_ENVIRONMENT", "local").strip().lower(),
|
||||
headless=_boolean("E2E_HEADLESS", True),
|
||||
slow_mo=int(os.getenv("E2E_SLOW_MO", "0")),
|
||||
action_timeout_ms=int(os.getenv("E2E_ACTION_TIMEOUT_MS", "10000")),
|
||||
navigation_timeout_ms=int(os.getenv("E2E_NAVIGATION_TIMEOUT_MS", "45000")),
|
||||
viewport={
|
||||
"width": int(os.getenv("E2E_VIEWPORT_WIDTH", "1280")),
|
||||
"height": int(os.getenv("E2E_VIEWPORT_HEIGHT", "800")),
|
||||
},
|
||||
artifact_dir=Path(os.getenv("E2E_ARTIFACT_DIR", "test-results/e2e")),
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.config import E2ESettings
|
||||
|
||||
|
||||
playwright_api = pytest.importorskip("playwright.sync_api")
|
||||
|
||||
# On Windows, ``localhost`` can resolve to IPv6 while Django's ephemeral live
|
||||
# server is listening on IPv4. Pin the test-only server to IPv4 so navigation
|
||||
# failures represent the application rather than resolver/bind ambiguity.
|
||||
os.environ.setdefault("DJANGO_LIVE_TEST_SERVER_ADDRESS", "127.0.0.1:0")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def e2e_settings():
|
||||
return E2ESettings.from_environment()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def application_base_url(live_server, e2e_settings):
|
||||
"""Configured deployment URL, falling back to Django's isolated local server."""
|
||||
return e2e_settings.base_url or live_server.url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser_page(request, e2e_settings):
|
||||
"""Return a page factory with per-test isolation and automatic failure artifacts."""
|
||||
|
||||
@contextmanager
|
||||
def open_page(*, service_workers="block"):
|
||||
with playwright_api.sync_playwright() as playwright:
|
||||
launch_options = {"headless": e2e_settings.headless, "slow_mo": e2e_settings.slow_mo}
|
||||
try:
|
||||
browser = playwright.chromium.launch(**launch_options)
|
||||
except playwright_api.Error as bundled_error:
|
||||
try:
|
||||
browser = playwright.chromium.launch(channel="chrome", **launch_options)
|
||||
except playwright_api.Error:
|
||||
raise RuntimeError(
|
||||
"No Chromium browser is available. Run: python -m playwright install chromium"
|
||||
) from bundled_error
|
||||
context = browser.new_context(
|
||||
viewport=e2e_settings.viewport,
|
||||
locale="fa-IR",
|
||||
timezone_id="Asia/Tehran",
|
||||
service_workers=service_workers,
|
||||
)
|
||||
context.set_default_timeout(e2e_settings.action_timeout_ms)
|
||||
context.set_default_navigation_timeout(e2e_settings.navigation_timeout_ms)
|
||||
context.tracing.start(screenshots=True, snapshots=True, sources=True)
|
||||
page = context.new_page()
|
||||
page_errors = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
safe_name = request.node.nodeid.replace("::", "__").replace("/", "_").replace("\\", "_")
|
||||
artifact_dir = e2e_settings.artifact_dir / safe_name
|
||||
failed = False
|
||||
try:
|
||||
yield page
|
||||
if page_errors:
|
||||
raise AssertionError(f"Browser JavaScript errors: {page_errors}")
|
||||
except BaseException:
|
||||
failed = True
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
page.screenshot(path=artifact_dir / "failure.png", full_page=True)
|
||||
raise
|
||||
finally:
|
||||
if failed:
|
||||
context.tracing.stop(path=artifact_dir / "trace.zip")
|
||||
else:
|
||||
context.tracing.stop()
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
return open_page
|
||||
@@ -0,0 +1 @@
|
||||
"""Reusable page objects for browser-level tests."""
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class AuthenticationPage(BasePage):
|
||||
def login_customer(self, customer, password):
|
||||
self.goto(reverse("accounts:password_login"))
|
||||
self.page.locator('input[name="mobile"]').fill(customer.local_mobile)
|
||||
self.page.locator('input[name="password"]').fill(password)
|
||||
self.page.get_by_role("button", name="ورود به حساب").click()
|
||||
expect(self.page).to_have_url(self.url(reverse("tickets:list")))
|
||||
|
||||
def login_staff(self, staff, password):
|
||||
self.goto(reverse("accounts:staff_login"))
|
||||
self.page.locator('input[name="username"]').fill(staff.username)
|
||||
self.page.locator('input[name="password"]').fill(password)
|
||||
self.page.get_by_role("button", name="ورود به میز کار").click()
|
||||
expect(self.page).to_have_url(self.url(reverse("staff_tickets:list")))
|
||||
@@ -0,0 +1,13 @@
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
class BasePage:
|
||||
def __init__(self, page, base_url):
|
||||
self.page = page
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
|
||||
def url(self, path):
|
||||
return urljoin(self.base_url, path.lstrip("/"))
|
||||
|
||||
def goto(self, path):
|
||||
return self.page.goto(self.url(path), wait_until="domcontentloaded")
|
||||
@@ -0,0 +1,31 @@
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class CustomerNewTicketPage(BasePage):
|
||||
def __init__(self, page, live_server_url: str):
|
||||
super().__init__(page, live_server_url)
|
||||
self.unit=page.locator("[data-ticket-unit]")
|
||||
self.category=page.locator("[data-ticket-category]")
|
||||
|
||||
def open(self):
|
||||
self.goto("/tickets/new/")
|
||||
expect(self.unit).to_be_visible()
|
||||
|
||||
def select_unit(self, unit_id: int, expected_category: str):
|
||||
with self.page.expect_response(lambda response: "/tickets/new/options/" in response.url and response.ok):
|
||||
self.unit.select_option(str(unit_id))
|
||||
expect(self.category.locator("option", has_text=expected_category)).to_have_count(1)
|
||||
|
||||
def search_and_choose(self, query: str, category_name: str):
|
||||
self.page.locator("[data-ticket-search]").fill(query)
|
||||
result=self.page.get_by_role(
|
||||
"button", name=re.compile(rf"^{re.escape(category_name)}")
|
||||
)
|
||||
expect(result).to_be_visible()
|
||||
result.click()
|
||||
option = self.category.get_by_role("option", name=category_name, exact=True)
|
||||
expect(option).to_have_count(1)
|
||||
expect(self.category).to_have_value(option.get_attribute("value"))
|
||||
@@ -0,0 +1,120 @@
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class TicketConversationPage(BasePage):
|
||||
guidance_text = "لطفاً همه توضیحات و سؤالهای خود را در یک پیام کامل بنویسید و سپس ارسال کنید."
|
||||
|
||||
def send_reply(self, body):
|
||||
self.page.locator("[data-composer-toggle]").click()
|
||||
reply = self.page.locator('form[data-chat-coach] textarea[name="body"]')
|
||||
expect(reply).to_be_visible()
|
||||
reply.fill(body)
|
||||
with self.page.expect_navigation(wait_until="domcontentloaded"):
|
||||
self.page.locator('form[data-chat-coach] [data-primary-submit]').click()
|
||||
assert "reply_result=reply" in self.page.url
|
||||
expect(self.page.locator("[data-conversation]")).to_contain_text(body)
|
||||
|
||||
@property
|
||||
def chat_guidance(self):
|
||||
return self.page.locator(".chat-coach-floating")
|
||||
|
||||
def expect_guidance_visible(self):
|
||||
expect(self.chat_guidance).to_be_visible()
|
||||
expect(self.chat_guidance).to_contain_text(self.guidance_text)
|
||||
|
||||
def expect_guidance_hidden(self):
|
||||
expect(self.chat_guidance).to_be_hidden()
|
||||
|
||||
def dismiss_guidance(self):
|
||||
self.chat_guidance.get_by_role("button", name="بستن راهنما").click()
|
||||
self.expect_guidance_hidden()
|
||||
|
||||
|
||||
class PublicTicketCreatePage(BasePage):
|
||||
def open(self):
|
||||
self.goto("/tickets/new/")
|
||||
expect(self.unit).to_be_visible()
|
||||
|
||||
@property
|
||||
def unit(self):
|
||||
return self.page.locator("[data-ticket-unit]")
|
||||
|
||||
@property
|
||||
def expert_picker(self):
|
||||
return self.page.locator("[data-expert-picker]")
|
||||
|
||||
@property
|
||||
def category(self):
|
||||
return self.page.locator("[data-ticket-category]")
|
||||
|
||||
@property
|
||||
def queue(self):
|
||||
return self.page.locator("[data-general-queue]")
|
||||
|
||||
@property
|
||||
def file_preview(self):
|
||||
return self.page.locator("[data-file-preview]")
|
||||
|
||||
def select_unit(self, unit_id):
|
||||
unit_id = str(unit_id)
|
||||
with self.page.expect_response(
|
||||
lambda response: "/tickets/new/options/" in response.url
|
||||
and f"unit={unit_id}" in response.url
|
||||
and response.status == 200
|
||||
) as response_info:
|
||||
self.unit.select_option(unit_id)
|
||||
payload = response_info.value.json()
|
||||
expect(self.page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", unit_id)
|
||||
expected_ids = {str(item["id"]) for item in payload["experts"]}
|
||||
actual_ids = set(self.expert_picker.locator('input[name="selected_expert"]:not([value=""])').evaluate_all("nodes => nodes.map(node => node.value)"))
|
||||
assert actual_ids == expected_ids
|
||||
return payload
|
||||
|
||||
def select_search_result(self, query, result_text):
|
||||
self.page.locator("[data-ticket-search]").fill(query)
|
||||
result = self.page.locator("[data-ticket-search-results] button").filter(has_text=result_text).first
|
||||
expect(result).to_be_visible()
|
||||
result.click()
|
||||
|
||||
def select_expert(self, display_name):
|
||||
card = self.expert_picker.locator("label.expert-card").filter(has_text=display_name)
|
||||
expect(card).to_have_count(1)
|
||||
radio = card.locator('input[name="selected_expert"]')
|
||||
radio.check()
|
||||
expect(radio).to_be_checked()
|
||||
return radio.get_attribute("value")
|
||||
|
||||
def select_general_queue(self):
|
||||
radio = self.queue.locator('input[name="selected_expert"]')
|
||||
radio.check()
|
||||
expect(radio).to_be_checked()
|
||||
|
||||
def select_category(self, category_id):
|
||||
self.category.select_option(str(category_id))
|
||||
expect(self.category).to_have_value(str(category_id))
|
||||
|
||||
def upload_gallery(self, files, *, total_count=None):
|
||||
self.page.locator("[data-gallery-input]").set_input_files(files)
|
||||
expect(self.file_preview.locator(".file-chip")).to_have_count(total_count or len(files))
|
||||
|
||||
def upload_documents(self, files, *, total_count=None):
|
||||
self.page.locator("[data-document-input]").set_input_files(files)
|
||||
expect(self.file_preview.locator(".file-chip")).to_have_count(total_count or len(files))
|
||||
|
||||
def remove_attachment(self, filename):
|
||||
chip = self.file_preview.locator(".file-chip").filter(has_text=filename)
|
||||
expect(chip).to_have_count(1)
|
||||
chip.get_by_role("button", name="حذف فایل").click()
|
||||
expect(self.file_preview.locator(".file-chip").filter(has_text=filename)).to_have_count(0)
|
||||
|
||||
def submit(self, *, subject, body):
|
||||
self.page.locator('input[name="subject"]').fill(subject)
|
||||
self.page.locator('textarea[name="body"]').fill(body)
|
||||
with self.page.expect_navigation(wait_until="domcontentloaded"):
|
||||
self.page.get_by_role("button", name="ثبت و ارسال درخواست").click()
|
||||
expect(self.page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$"))
|
||||
expect(self.page.locator("h1")).to_have_text(subject)
|
||||
@@ -0,0 +1,78 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.staff.models import StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.tickets import TicketConversationPage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-safe-password-2026"
|
||||
|
||||
|
||||
def test_customer_sees_chat_guidance_after_two_rapid_messages(live_server, browser_page):
|
||||
category = TicketCategory.objects.create(title="فروش E2E", slug="e2e-customer-chat")
|
||||
customer = User.objects.create_user(
|
||||
username="e2e-customer-chat",
|
||||
mobile="09120009001",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
CustomerProfile.objects.create(user=customer, first_name="مشتری", last_name="آزمایشی")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="تست هشدار مشتری", body="سلام")
|
||||
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
conversation = TicketConversationPage(page, live_server.url)
|
||||
conversation.goto(reverse("tickets:detail", args=[ticket.public_id]))
|
||||
conversation.expect_guidance_hidden()
|
||||
conversation.send_reply("قیمت چنده؟")
|
||||
conversation.expect_guidance_visible()
|
||||
conversation.dismiss_guidance()
|
||||
conversation.send_reply("موجود دارید؟")
|
||||
conversation.expect_guidance_visible()
|
||||
conversation.dismiss_guidance()
|
||||
|
||||
|
||||
def test_staff_sees_chat_guidance_after_two_rapid_replies(live_server, browser_page):
|
||||
category = TicketCategory.objects.create(title="پشتیبانی E2E", slug="e2e-staff-chat")
|
||||
customer = User.objects.create_user(
|
||||
username="e2e-staff-customer",
|
||||
mobile="09120009002",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
staff = User.objects.create_user(
|
||||
username="e2e-staff-chat",
|
||||
mobile="09120009003",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.STAFF,
|
||||
)
|
||||
StaffProfile.objects.create(user=staff, display_name="کارشناس E2E")
|
||||
ticket = create_ticket(
|
||||
customer=customer,
|
||||
category=category,
|
||||
subject="تست هشدار کارشناس",
|
||||
selected_expert=staff,
|
||||
body="درخواست مشتری",
|
||||
)
|
||||
ticket.owner = staff
|
||||
ticket.selected_expert = None
|
||||
ticket.save(update_fields=["owner", "selected_expert", "updated_at"])
|
||||
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
|
||||
conversation = TicketConversationPage(page, live_server.url)
|
||||
conversation.goto(reverse("staff_tickets:detail", args=[ticket.public_id]))
|
||||
conversation.send_reply("پاسخ اول کارشناس")
|
||||
conversation.expect_guidance_hidden()
|
||||
conversation.send_reply("پاسخ دوم کارشناس")
|
||||
conversation.expect_guidance_visible()
|
||||
conversation.dismiss_guidance()
|
||||
conversation.send_reply("پاسخ سوم کارشناس")
|
||||
conversation.expect_guidance_visible()
|
||||
conversation.dismiss_guidance()
|
||||
@@ -0,0 +1,73 @@
|
||||
import pytest
|
||||
import re
|
||||
from playwright.sync_api import expect
|
||||
from django.core.management import call_command
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
from apps.tickets.models import TicketCategory
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.customer_new_ticket import CustomerNewTicketPage
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_customer_switches_unit_and_sees_its_categories(live_server, browser_page):
|
||||
customer=User.objects.create_user(username="browser-customer",mobile="09120009991",password="StrongPass123!",account_type=User.AccountType.CUSTOMER)
|
||||
sales=OrganizationalUnit.objects.create(name="فروش مرورگر",slug="e2e-sales",accepts_external_tickets=True)
|
||||
finance=OrganizationalUnit.objects.create(name="حسابداری مرورگر",slug="e2e-finance",accepts_external_tickets=True)
|
||||
TicketCategory.objects.create(unit=sales,title="خرید کالا",slug="e2e-buy")
|
||||
TicketCategory.objects.create(
|
||||
unit=finance,
|
||||
title="ارسال فیش",
|
||||
slug="e2e-receipt",
|
||||
search_keywords="فیش آزمایشی مرورگر اختصاصی",
|
||||
)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page,live_server.url).login_customer(customer,"StrongPass123!")
|
||||
ticket_page=CustomerNewTicketPage(page,live_server.url)
|
||||
ticket_page.open()
|
||||
ticket_page.select_unit(finance.pk,"ارسال فیش")
|
||||
expect(ticket_page.category.locator("option",has_text="خرید کالا")).to_have_count(0)
|
||||
ticket_page.search_and_choose("فیش آزمایشی مرورگر اختصاصی","ارسال فیش")
|
||||
expect(page.locator('[name="province"]')).to_have_js_property("tagName","SELECT")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.parametrize(
|
||||
("query", "category_name", "unit_slug"),
|
||||
[
|
||||
("بار من کجاست و کی میرسه", "پیگیری بارگیری و ارسال", "sales"),
|
||||
("کپی فاکتور رسمی", "فاکتور و صورتحساب", "finance"),
|
||||
("تسویه حساب تامین کننده", "تسویه تأمینکنندگان", "finance"),
|
||||
],
|
||||
ids=("shipping", "invoice", "supplier"),
|
||||
)
|
||||
def test_customer_natural_language_search_selects_helpful_catalog_route(
|
||||
live_server, browser_page, query, category_name, unit_slug
|
||||
):
|
||||
call_command("bootstrap")
|
||||
category = TicketCategory.objects.get(title=category_name)
|
||||
unit_id = OrganizationalUnit.objects.get(slug=unit_slug).pk
|
||||
customer = User.objects.create_user(
|
||||
username=f"catalog-browser-{unit_slug}-{category.pk}",
|
||||
mobile=f"0912{category.pk:07d}",
|
||||
password="StrongPass123!",
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, "StrongPass123!")
|
||||
ticket_page = CustomerNewTicketPage(page, live_server.url)
|
||||
ticket_page.open()
|
||||
page.locator("[data-ticket-search]").fill(query)
|
||||
result = page.get_by_role(
|
||||
"button", name=re.compile(rf"^{re.escape(category_name)}")
|
||||
)
|
||||
expect(result).to_be_visible()
|
||||
expect(result.locator("small")).not_to_have_text("انتخاب این مسیر برای درخواست")
|
||||
result.click()
|
||||
expect(ticket_page.unit).to_have_value(str(unit_id))
|
||||
expect(ticket_page.category).to_have_value(
|
||||
str(category.pk)
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.external_e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
def require_setting(name):
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
pytest.skip(f"{name} is not configured for external E2E")
|
||||
return value
|
||||
|
||||
|
||||
def test_external_customer_login_and_public_routing_are_usable(
|
||||
browser_page, application_base_url, e2e_settings
|
||||
):
|
||||
if not e2e_settings.base_url:
|
||||
pytest.skip("E2E_BASE_URL/BASE_URL is not configured; external environment was not run")
|
||||
mobile = require_setting("E2E_CUSTOMER_MOBILE")
|
||||
password = require_setting("E2E_CUSTOMER_PASSWORD")
|
||||
with browser_page() as page:
|
||||
page.goto(f"{application_base_url}/login/password/", wait_until="domcontentloaded")
|
||||
page.locator('input[name="mobile"]').fill(mobile)
|
||||
page.locator('input[name="password"]').fill(password)
|
||||
page.get_by_role("button", name="ورود به حساب").click()
|
||||
expect(page).to_have_url(f"{application_base_url}/tickets/")
|
||||
page.goto(f"{application_base_url}/tickets/new/", wait_until="domcontentloaded")
|
||||
expect(page.locator("[data-ticket-unit]")).to_be_visible()
|
||||
expect(page.locator("[data-ticket-category]")).to_be_visible()
|
||||
expect(page.locator("[data-expert-picker]")).to_be_visible()
|
||||
|
||||
|
||||
def test_external_staff_login_and_navigation_are_usable(
|
||||
browser_page, application_base_url, e2e_settings
|
||||
):
|
||||
if not e2e_settings.base_url:
|
||||
pytest.skip("E2E_BASE_URL/BASE_URL is not configured; external environment was not run")
|
||||
username = require_setting("E2E_STAFF_USERNAME")
|
||||
password = require_setting("E2E_STAFF_PASSWORD")
|
||||
with browser_page() as page:
|
||||
page.goto(f"{application_base_url}/staff/login/", wait_until="domcontentloaded")
|
||||
page.locator('input[name="username"]').fill(username)
|
||||
page.locator('input[name="password"]').fill(password)
|
||||
page.get_by_role("button", name="ورود به میز کار").click()
|
||||
expect(page).to_have_url(f"{application_base_url}/staff/tickets/")
|
||||
response = page.goto(f"{application_base_url}/staff/tickets/new/", wait_until="domcontentloaded")
|
||||
assert response.status == 200
|
||||
expect(page.locator("[data-staff-unit]")).to_be_visible()
|
||||
@@ -0,0 +1,245 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
from apps.tickets.models import GroupDispatch, TicketCategory
|
||||
from apps.tickets.services import create_ticket, dispatch_group_tickets
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.tickets import PublicTicketCreatePage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-org-permissions-2026"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_data():
|
||||
call_command("bootstrap")
|
||||
users = {name: User.objects.get(username=name) for name in (
|
||||
"kimiya_ghaffari", "maryam_fotovat", "morteza_sokhaeifard", "ms_sabzali",
|
||||
"mehdi_azami", "fereshteh_yavari", "mostafa_sokhaeifard"
|
||||
)}
|
||||
for user in users.values():
|
||||
user.set_password(PASSWORD)
|
||||
user.save(update_fields=["password"])
|
||||
units = {slug: OrganizationalUnit.objects.get(slug=slug) for slug in ("sales", "warehouse-loading", "finance")}
|
||||
return users, units
|
||||
|
||||
|
||||
def make_customer(username, mobile, password=None):
|
||||
user = User.objects.create_user(username=username, mobile=mobile, password=password, account_type=User.AccountType.CUSTOMER)
|
||||
CustomerProfile.objects.create(user=user, first_name=username)
|
||||
return user
|
||||
|
||||
|
||||
def login_staff(page, live_server, user):
|
||||
AuthenticationPage(page, live_server.url).login_staff(user, PASSWORD)
|
||||
|
||||
|
||||
def test_e2e_01_sales_expert_sees_locked_unit_and_sales_categories(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["kimiya_ghaffari"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded")
|
||||
expect(page.locator("[data-staff-unit]")).to_have_text("فروش")
|
||||
expect(page.locator('input[type="hidden"][name="unit"]')).to_have_value(str(units["sales"].pk))
|
||||
categories = page.locator('select[name="category"] option')
|
||||
expect(categories.filter(has_text="عمومی فروش")).to_have_count(1)
|
||||
expect(categories.filter(has_text="مالی و حسابداری")).to_have_count(0)
|
||||
groups = page.locator('select[name="groups"] option')
|
||||
expect(groups.filter(has_text="مخاطبان فروش")).to_have_count(1)
|
||||
expect(groups.filter(has_text="مخاطبان مالی")).to_have_count(0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("username", ["maryam_fotovat", "morteza_sokhaeifard"])
|
||||
def test_e2e_02_sales_leadership_can_login_navigate_and_create_in_its_unit(
|
||||
live_server, browser_page, org_data, username
|
||||
):
|
||||
users, units = org_data
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users[username])
|
||||
expect(page).to_have_url(f"{live_server.url}{reverse('staff_tickets:list')}")
|
||||
response = page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded")
|
||||
assert response.status == 200
|
||||
expect(page.locator("[data-staff-unit]")).to_have_text("فروش")
|
||||
expect(page.locator('input[name="unit"]')).to_have_value(str(units["sales"].pk))
|
||||
|
||||
|
||||
def test_e2e_05_ceo_can_login_and_open_broad_dispatch_workflow(live_server, browser_page, org_data):
|
||||
users, _ = org_data
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["mostafa_sokhaeifard"])
|
||||
expect(page).to_have_url(f"{live_server.url}{reverse('staff_tickets:list')}")
|
||||
response = page.goto(
|
||||
f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded"
|
||||
)
|
||||
assert response.status == 200
|
||||
expect(page.locator("body")).to_contain_text("ارسال گروهی")
|
||||
|
||||
|
||||
def test_e2e_02_contact_group_picker_excludes_other_units(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
sales_group = ContactGroup.objects.get(unit=units["sales"], name__startswith="مخاطبان")
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["kimiya_ghaffari"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:contact_create')}", wait_until="domcontentloaded")
|
||||
options = page.locator('select[name="groups"] option')
|
||||
expect(options.filter(has_text="مخاطبان فروش")).to_have_count(1)
|
||||
expect(options.filter(has_text="بارگیری انبار")).to_have_count(0)
|
||||
expect(options.filter(has_text="مالی / حسابداری")).to_have_count(0)
|
||||
page.locator('input[name="mobile"]').fill("09120008119")
|
||||
page.locator('input[name="first_name"]').fill("مخاطب")
|
||||
page.locator('input[name="last_name"]').fill("فروش")
|
||||
page.locator('select[name="stakeholder_type"]').select_option(CustomerProfile.StakeholderType.CUSTOMER)
|
||||
page.locator('select[name="groups"]').select_option(str(sales_group.pk))
|
||||
with page.expect_navigation(wait_until="domcontentloaded"):
|
||||
page.get_by_role("button", name="ذخیره مخاطب").click()
|
||||
expect(page.locator("body")).to_contain_text("مخاطب فروش")
|
||||
contact = User.objects.get(mobile="+989120008119")
|
||||
assert list(contact.customer_profile.groups.all()) == [sales_group]
|
||||
|
||||
|
||||
def test_e2e_02_staff_ticket_creation_finishes_with_sales_contact_group(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
sales_group = ContactGroup.objects.get(unit=units["sales"], name__startswith="مخاطبان")
|
||||
category = TicketCategory.objects.get(slug="sales-general")
|
||||
subject = "گردش کامل ایجاد تیکت Staff E2E"
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["kimiya_ghaffari"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded")
|
||||
page.locator('input[name="customer_mobile"]').fill("09120008118")
|
||||
page.locator('input[name="first_name"]').fill("مشتری")
|
||||
page.locator('input[name="last_name"]').fill("فروش")
|
||||
page.locator('select[name="groups"]').select_option(str(sales_group.pk))
|
||||
page.locator('select[name="category"]').select_option(str(category.pk))
|
||||
page.locator('input[name="subject"]').fill(subject)
|
||||
page.locator('textarea[name="body"]').fill("شرح کامل ثبت توسط کارشناس فروش")
|
||||
with page.expect_navigation(wait_until="domcontentloaded"):
|
||||
page.get_by_role("button", name="ثبت تیکت و ارسال لینک امن").click()
|
||||
expect(page.locator("h1")).to_have_text(subject)
|
||||
ticket = TicketCategory.objects.get(pk=category.pk).tickets.get(subject=subject)
|
||||
assert ticket.owning_unit == units["sales"]
|
||||
assert ticket.owner == users["kimiya_ghaffari"]
|
||||
assert sales_group in ticket.customer.customer_profile.groups.all()
|
||||
|
||||
|
||||
def test_e2e_03_warehouse_staff_cannot_see_sales_tickets(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
buyer = make_customer("e2e-scope-buyer", "09120008101")
|
||||
create_ticket(customer=buyer, unit=units["sales"], category=TicketCategory.objects.get(slug="sales-general"), subject="تیکت محرمانه فروش E2E", body="شرح")
|
||||
create_ticket(customer=buyer, unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت مجاز بارگیری E2E", body="شرح")
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["ms_sabzali"])
|
||||
content = page.locator("body")
|
||||
expect(content).to_contain_text("تیکت مجاز بارگیری E2E")
|
||||
expect(content).not_to_contain_text("تیکت محرمانه فروش E2E")
|
||||
|
||||
|
||||
def test_e2e_04_accounting_sees_only_finance_scope(live_server, browser_page, org_data):
|
||||
users, units = org_data
|
||||
buyer = make_customer("e2e-finance-buyer", "09120008102")
|
||||
create_ticket(customer=buyer, unit=units["finance"], category=TicketCategory.objects.get(slug="finance-general"), subject="تیکت مالی مجاز E2E", body="شرح")
|
||||
create_ticket(customer=buyer, unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت انبار نامجاز E2E", body="شرح")
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["fereshteh_yavari"])
|
||||
expect(page.locator("body")).to_contain_text("تیکت مالی مجاز E2E")
|
||||
expect(page.locator("body")).not_to_contain_text("تیکت انبار نامجاز E2E")
|
||||
|
||||
|
||||
def prepare_operation(users, units, operation_type, suffix):
|
||||
warehouse = units["warehouse-loading"]
|
||||
contact = make_customer(f"e2e-operation-contact-{suffix}", f"091200082{suffix}")
|
||||
group = ContactGroup.objects.create(unit=warehouse, name=f"گروه عملیات E2E {suffix}")
|
||||
contact.customer_profile.groups.add(group)
|
||||
category = TicketCategory.objects.get(slug="warehouse-loading-general")
|
||||
other = users["mehdi_azami"]
|
||||
codename = "create_bulk_dispatch" if operation_type == GroupDispatch.OperationType.BULK else "create_inquiry"
|
||||
other.unit_memberships.get(unit=warehouse).permissions.add(Permission.objects.get(content_type__app_label="tickets", codename=codename))
|
||||
foreign, _ = dispatch_group_tickets(user=other, unit=warehouse, contact_group=group, category=category, operation_type=operation_type, subject=f"عملیات متعلق به مهدی {suffix}", body="متن عملیات")
|
||||
return group, category, foreign
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation_type,suffix", [(GroupDispatch.OperationType.BULK, "03"), (GroupDispatch.OperationType.INQUIRY, "04")])
|
||||
def test_e2e_05_06_limited_operation_owner_and_ceo_view_all(live_server, browser_page, org_data, operation_type, suffix):
|
||||
users, units = org_data
|
||||
group, category, foreign = prepare_operation(users, units, operation_type, suffix)
|
||||
own_subject = "ارسال گروهی سبزعلی E2E" if operation_type == GroupDispatch.OperationType.BULK else "استعلام سبزعلی E2E"
|
||||
with browser_page() as page:
|
||||
login_staff(page, live_server, users["ms_sabzali"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded")
|
||||
expect(page.locator("body")).not_to_contain_text(foreign.subject)
|
||||
composer = page.locator(".dispatch-composer")
|
||||
composer.locator('select[name="operation_type"]').select_option(operation_type)
|
||||
composer.locator('select[name="unit"]').select_option(str(units["warehouse-loading"].pk))
|
||||
composer.locator('select[name="contact_group"]').select_option(str(group.pk))
|
||||
composer.locator('select[name="category"]').select_option(str(category.pk))
|
||||
composer.locator('input[name="subject"]').fill(own_subject)
|
||||
composer.locator('textarea[name="body"]').fill("متن کامل عملیات E2E")
|
||||
page.get_by_role("button", name="ایجاد تیکتها").click()
|
||||
expect(page.locator("body")).to_contain_text(own_subject)
|
||||
page.context.clear_cookies()
|
||||
login_staff(page, live_server, users["mostafa_sokhaeifard"])
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded")
|
||||
expect(page.locator("body")).to_contain_text(own_subject)
|
||||
expect(page.locator("body")).to_contain_text(foreign.subject)
|
||||
|
||||
|
||||
def test_e2e_07_manual_unit_change_refreshes_staff_and_queue(live_server, browser_page, org_data):
|
||||
_, units = org_data
|
||||
buyer = make_customer("e2e-dynamic-manual", "09120008105", PASSWORD)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(buyer, PASSWORD)
|
||||
page.goto(f"{live_server.url}{reverse('tickets:new')}", wait_until="domcontentloaded")
|
||||
ticket_page = PublicTicketCreatePage(page, live_server.url)
|
||||
sales_payload = ticket_page.select_unit(units["sales"].pk)
|
||||
assert {item["name"] for item in sales_payload["experts"]}.issuperset({"کیمیا غفاری", "زهرا جمالی"})
|
||||
expect(page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", str(units["sales"].pk))
|
||||
expect(page.locator("[data-general-queue]")).to_contain_text("صف عمومی فروش")
|
||||
expect(page.locator("[data-expert-picker]")).to_contain_text("کیمیا غفاری")
|
||||
finance_payload = ticket_page.select_unit(units["finance"].pk)
|
||||
assert {item["name"] for item in finance_payload["experts"]}.issuperset({"خانم فرشته یاوری"})
|
||||
expect(page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", str(units["finance"].pk))
|
||||
expect(page.locator("[data-general-queue]")).to_contain_text("صف عمومی مالی / حسابداری")
|
||||
expect(page.locator("[data-expert-picker]")).to_contain_text("خانم فرشته یاوری")
|
||||
expect(page.locator("[data-expert-picker]")).not_to_contain_text("کیمیا غفاری")
|
||||
|
||||
|
||||
def test_e2e_08_search_selection_uses_same_unit_staff_queue_pipeline(live_server, browser_page, org_data):
|
||||
_, units = org_data
|
||||
buyer = make_customer("e2e-dynamic-search", "09120008106", PASSWORD)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(buyer, PASSWORD)
|
||||
page.goto(f"{live_server.url}{reverse('tickets:new')}", wait_until="domcontentloaded")
|
||||
ticket_page = PublicTicketCreatePage(page, live_server.url)
|
||||
ticket_page.select_search_result("عمومی فروش", "عمومی فروش")
|
||||
expect(page.locator('[data-ticket-unit]')).to_have_value(str(units["sales"].pk))
|
||||
expect(page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", str(units["sales"].pk))
|
||||
expect(page.locator("[data-expert-picker]")).to_contain_text("کیمیا غفاری")
|
||||
expect(page.locator("[data-expert-picker]")).not_to_contain_text("خانم فرشته یاوری")
|
||||
|
||||
|
||||
def test_unit_change_renders_experts_when_a_legacy_cached_payload_has_no_queue(live_server, browser_page, org_data):
|
||||
_, units = org_data
|
||||
buyer = make_customer("e2e-legacy-options-payload", "09120008107", PASSWORD)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(buyer, PASSWORD)
|
||||
|
||||
def remove_queue_from_response(route):
|
||||
response = route.fetch()
|
||||
payload = response.json()
|
||||
payload.pop("queue", None)
|
||||
route.fulfill(response=response, json=payload)
|
||||
|
||||
page.route("**/tickets/new/options/**", remove_queue_from_response)
|
||||
page.goto(f"{live_server.url}{reverse('tickets:new')}", wait_until="domcontentloaded")
|
||||
ticket_page = PublicTicketCreatePage(page, live_server.url)
|
||||
payload = ticket_page.select_unit(units["warehouse-loading"].pk)
|
||||
assert payload.get("queue") is None
|
||||
expect(ticket_page.expert_picker).to_contain_text("خانم سبزعلی")
|
||||
expect(ticket_page.expert_picker).to_contain_text("آقای مهدی عظمی")
|
||||
expect(page.get_by_role("alert")).to_have_count(0)
|
||||
@@ -0,0 +1,290 @@
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.tickets import PublicTicketCreatePage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-public-workflow-2026"
|
||||
|
||||
|
||||
def image_file(name, image_format, color):
|
||||
pillow = pytest.importorskip("PIL.Image")
|
||||
stream = io.BytesIO()
|
||||
pillow.new("RGB", (8, 8), color).save(stream, format=image_format)
|
||||
return {
|
||||
"name": name,
|
||||
"mimeType": "image/jpeg" if image_format == "JPEG" else "image/png",
|
||||
"buffer": stream.getvalue(),
|
||||
}
|
||||
|
||||
|
||||
def pdf_file(name, marker="one"):
|
||||
return {
|
||||
"name": name,
|
||||
"mimeType": "application/pdf",
|
||||
"buffer": (
|
||||
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
"2 0 obj<</Type/Pages/Count 0/Kids[]>>endobj\n"
|
||||
f"% {marker}\n%%EOF\n"
|
||||
).encode(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_workflow(settings, tmp_path):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
call_command("bootstrap")
|
||||
customer = User.objects.create_user(
|
||||
username="e2e-public-workflow",
|
||||
mobile="09120008301",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
CustomerProfile.objects.create(user=customer, first_name="مشتری", last_name="مرورگر")
|
||||
units = {
|
||||
slug: OrganizationalUnit.objects.get(slug=slug)
|
||||
for slug in ("sales", "warehouse-loading", "finance")
|
||||
}
|
||||
categories = {
|
||||
"sales_general": TicketCategory.objects.get(slug="sales-general"),
|
||||
"sales_secondary": TicketCategory.objects.create(
|
||||
unit=units["sales"],
|
||||
title="شرایط فروش E2E",
|
||||
slug="sales-secondary-e2e",
|
||||
is_active=True,
|
||||
show_in_customer_form=True,
|
||||
),
|
||||
"warehouse": TicketCategory.objects.get(slug="warehouse-loading-general"),
|
||||
"finance": TicketCategory.objects.get(slug="finance-general"),
|
||||
}
|
||||
return customer, units, categories
|
||||
|
||||
|
||||
def open_form(page, live_server, customer):
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
form = PublicTicketCreatePage(page, live_server.url)
|
||||
form.open()
|
||||
return form
|
||||
|
||||
|
||||
def assert_ticket_record(subject, *, unit, category, expert_name, attachment_names=()):
|
||||
ticket = Ticket.objects.select_related("selected_expert__staff_profile").get(subject=subject)
|
||||
assert ticket.owning_unit == unit
|
||||
assert ticket.assigned_unit == unit
|
||||
assert ticket.category == category
|
||||
actual_expert = ticket.selected_expert.staff_profile.display_name if ticket.selected_expert_id else "صف عمومی"
|
||||
assert actual_expert == expert_name
|
||||
attachments = ticket.ticket_messages.get().attachments.order_by("original_name")
|
||||
assert set(attachments.values_list("original_name", flat=True)) == set(attachment_names)
|
||||
|
||||
|
||||
def assert_final_ticket_ui(page, *, category, expert_name, attachment_names=()):
|
||||
expect(page.locator("#ticket-info")).to_contain_text(category.title)
|
||||
expect(page.locator("#ticket-info")).to_contain_text(expert_name)
|
||||
for name in attachment_names:
|
||||
expect(page.locator("[data-conversation]")).to_contain_text(name)
|
||||
|
||||
|
||||
def test_e2e_07_sales_general_queue_creates_verifiable_ticket(live_server, browser_page, public_workflow):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "ثبت واقعی در صف عمومی فروش E2E"
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.select_general_queue()
|
||||
form.submit(subject=subject, body="شرح کامل تیکت بدون پیوست")
|
||||
assert_final_ticket_ui(page, category=categories["sales_general"], expert_name="صف عمومی")
|
||||
assert_ticket_record(
|
||||
subject, unit=units["sales"], category=categories["sales_general"], expert_name="صف عمومی"
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_08_repeated_staff_changes_store_only_last_choice(live_server, browser_page, public_workflow):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "آخرین کارشناس انتخابی E2E"
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
for name in ("کیمیا غفاری", "زهرا جمالی", "مریم فتوت", "زهرا جمالی"):
|
||||
form.select_expert(name)
|
||||
form.submit(subject=subject, body="تغییر چندباره کارشناس")
|
||||
assert_final_ticket_ui(page, category=categories["sales_general"], expert_name="زهرا جمالی")
|
||||
assert_ticket_record(
|
||||
subject, unit=units["sales"], category=categories["sales_general"], expert_name="زهرا جمالی"
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_subject_stays_empty_for_manual_and_search_routing_until_user_types(
|
||||
live_server, browser_page, public_workflow
|
||||
):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "موضوع کاملاً دستی E2E"
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
subject_input = page.locator("[data-ticket-subject]")
|
||||
unit_box = form.unit.bounding_box()
|
||||
category_box = form.category.bounding_box()
|
||||
assert unit_box and category_box
|
||||
assert abs(unit_box["y"] - category_box["y"]) < 2
|
||||
assert unit_box["x"] + unit_box["width"] <= category_box["x"] or category_box["x"] + category_box["width"] <= unit_box["x"]
|
||||
expect(subject_input).to_have_value("")
|
||||
expect(subject_input).to_have_attribute("required", "")
|
||||
form.select_unit(units["finance"].pk)
|
||||
form.select_category(categories["finance"].pk)
|
||||
expect(subject_input).to_have_value("")
|
||||
form.select_search_result("عمومی فروش", "عمومی فروش")
|
||||
expect(form.unit).to_have_value(str(units["sales"].pk))
|
||||
expect(subject_input).to_have_value("")
|
||||
subject_input.fill(subject)
|
||||
form.select_unit(units["finance"].pk)
|
||||
expect(subject_input).to_have_value(subject)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.select_general_queue()
|
||||
form.submit(subject=subject, body="موضوع فقط توسط کاربر وارد شده است")
|
||||
assert_final_ticket_ui(page, category=categories["sales_general"], expert_name="صف عمومی")
|
||||
assert_ticket_record(
|
||||
subject, unit=units["sales"], category=categories["sales_general"], expert_name="صف عمومی"
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_experts_follow_configured_numeric_order(live_server, browser_page, public_workflow):
|
||||
customer, units, _ = public_workflow
|
||||
configured = {
|
||||
"زهرا جمالی": 10,
|
||||
"مریم فتوت": 20,
|
||||
"کیمیا غفاری": 30,
|
||||
"مرتضی سخائی فرد": 40,
|
||||
}
|
||||
for name, order in configured.items():
|
||||
StaffProfile.objects.filter(primary_unit=units["sales"], display_name=name).update(sort_order=order)
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
names = form.expert_picker.locator(
|
||||
"label.expert-card:not([data-general-queue]) strong"
|
||||
).all_text_contents()
|
||||
assert names == ["زهرا جمالی", "مریم فتوت", "کیمیا غفاری", "مرتضی سخائی فرد"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"suffix,images,documents,expected",
|
||||
[
|
||||
("jpg", [image_file("photo.jpg", "JPEG", "red")], [], ["photo.jpg"]),
|
||||
("pdf", [], [pdf_file("document.pdf")], ["document.pdf"]),
|
||||
(
|
||||
"mixed",
|
||||
[image_file("first.jpg", "JPEG", "blue"), image_file("second.png", "PNG", "green")],
|
||||
[pdf_file("combined.pdf")],
|
||||
["first.jpg", "second.png", "combined.pdf"],
|
||||
),
|
||||
(
|
||||
"multi-file",
|
||||
[],
|
||||
[pdf_file("first.pdf", "first"), pdf_file("second.pdf", "second")],
|
||||
["first.pdf", "second.pdf"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_e2e_09_10_11_attachment_variants_are_saved_and_rendered(
|
||||
live_server, browser_page, public_workflow, suffix, images, documents, expected
|
||||
):
|
||||
customer, units, categories = public_workflow
|
||||
subject = f"پیوست واقعی {suffix} E2E"
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.select_general_queue()
|
||||
if images:
|
||||
form.upload_gallery(images)
|
||||
if documents:
|
||||
form.upload_documents(documents, total_count=len(images) + len(documents))
|
||||
form.submit(subject=subject, body="بررسی ذخیره و نمایش تمام پیوستها")
|
||||
assert_final_ticket_ui(
|
||||
page, category=categories["sales_general"], expert_name="صف عمومی", attachment_names=expected
|
||||
)
|
||||
assert_ticket_record(
|
||||
subject,
|
||||
unit=units["sales"],
|
||||
category=categories["sales_general"],
|
||||
expert_name="صف عمومی",
|
||||
attachment_names=expected,
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_11_removed_attachment_is_not_submitted(live_server, browser_page, public_workflow):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "حذف پیوست پیش از ثبت E2E"
|
||||
keep = image_file("keep.png", "PNG", "yellow")
|
||||
remove = pdf_file("remove.pdf")
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.upload_gallery([keep])
|
||||
form.upload_documents([remove], total_count=2)
|
||||
form.remove_attachment("remove.pdf")
|
||||
form.submit(subject=subject, body="فقط تصویر باید ذخیره شود")
|
||||
assert_final_ticket_ui(
|
||||
page, category=categories["sales_general"], expert_name="صف عمومی", attachment_names=["keep.png"]
|
||||
)
|
||||
assert_ticket_record(
|
||||
subject,
|
||||
unit=units["sales"],
|
||||
category=categories["sales_general"],
|
||||
expert_name="صف عمومی",
|
||||
attachment_names=["keep.png"],
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_12_sequential_unit_staff_category_and_attachment_state(live_server, browser_page, public_workflow):
|
||||
customer, units, categories = public_workflow
|
||||
subject = "مدیریت وضعیت متوالی فرم E2E"
|
||||
image_one = image_file("state-one.jpg", "JPEG", "purple")
|
||||
image_two = image_file("state-two.png", "PNG", "orange")
|
||||
removed_pdf = pdf_file("state-remove.pdf")
|
||||
with browser_page() as page:
|
||||
form = open_form(page, live_server, customer)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_expert("کیمیا غفاری")
|
||||
form.select_category(categories["sales_secondary"].pk)
|
||||
form.upload_gallery([image_one])
|
||||
form.upload_documents([removed_pdf], total_count=2)
|
||||
form.select_unit(units["finance"].pk)
|
||||
expect(form.expert_picker).not_to_contain_text("کیمیا غفاری")
|
||||
expect(form.queue).to_contain_text("صف عمومی مالی / حسابداری")
|
||||
form.select_category(categories["finance"].pk)
|
||||
form.select_unit(units["sales"].pk)
|
||||
form.select_category(categories["sales_general"].pk)
|
||||
form.select_expert("کیمیا غفاری")
|
||||
form.select_expert("زهرا جمالی")
|
||||
form.select_expert("کیمیا غفاری")
|
||||
form.remove_attachment("state-remove.pdf")
|
||||
form.upload_gallery([image_two], total_count=2)
|
||||
expect(form.file_preview.locator(".file-chip")).to_have_count(2)
|
||||
form.submit(subject=subject, body="فرم پس از تغییرات متوالی سالم ماند")
|
||||
assert_final_ticket_ui(
|
||||
page,
|
||||
category=categories["sales_general"],
|
||||
expert_name="کیمیا غفاری",
|
||||
attachment_names=["state-one.jpg", "state-two.png"],
|
||||
)
|
||||
assert_ticket_record(
|
||||
subject,
|
||||
unit=units["sales"],
|
||||
category=categories["sales_general"],
|
||||
expert_name="کیمیا غفاری",
|
||||
attachment_names=["state-one.jpg", "state-two.png"],
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.accounts.models import User
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.pwa, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-safe-password-2026"
|
||||
|
||||
|
||||
def test_pwa_registers_versioned_worker_and_matching_cache(live_server, browser_page):
|
||||
customer = User.objects.create_user(
|
||||
username="e2e-pwa-version",
|
||||
mobile="09120009010",
|
||||
password=PASSWORD,
|
||||
account_type=User.AccountType.CUSTOMER,
|
||||
)
|
||||
|
||||
with browser_page(service_workers="allow") as page:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
|
||||
state = page.evaluate(
|
||||
"""async () => {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const worker = registration.active || registration.waiting || registration.installing;
|
||||
return {
|
||||
version: document.documentElement.dataset.staticVersion,
|
||||
scriptURL: worker && worker.scriptURL,
|
||||
cacheNames: await caches.keys(),
|
||||
};
|
||||
}"""
|
||||
)
|
||||
|
||||
assert state["version"]
|
||||
assert f"/service-worker.js?v={quote(state['version'])}" in state["scriptURL"]
|
||||
assert f"sales-ticket-{state['version']}" in state["cacheNames"]
|
||||
|
||||
worker_response = page.request.get(state["scriptURL"])
|
||||
assert worker_response.ok
|
||||
assert worker_response.headers["cache-control"] == "no-cache, no-store, must-revalidate"
|
||||
@@ -0,0 +1,88 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.accounts.models import TicketMagicLink, User
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.core.models import PageTitle, SiteBranding
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "E2e-safe-password-2026"
|
||||
|
||||
|
||||
def test_staff_operations_pages_are_authorized_and_visually_styled(live_server, browser_page):
|
||||
branding = SiteBranding.load()
|
||||
branding.staff_brand_title = "مرکز عملیات E2E"
|
||||
branding.staff_brand_subtitle = "ارتباطات یکپارچه E2E"
|
||||
branding.login_intro_eyebrow = "معرفی ورود E2E"
|
||||
branding.developer_credit_text = "توسعه E2E | 09120000000"
|
||||
branding.staff_sidebar_credit_text = "امضای مستقل sidebar E2E"
|
||||
branding.save()
|
||||
PageTitle.objects.update_or_create(route_name="staff_tickets:dispatch_create", defaults={"title": "ردیابی E2E"})
|
||||
SMSTemplate.objects.update_or_create(unit=None, event_type=SMSTemplate.Event.STAFF_REPLY, defaults={"title": "الگوی E2E", "body": "{ticket_number}: {magic_link}"})
|
||||
unit = OrganizationalUnit.objects.create(name="عملیات E2E", slug="operations-e2e")
|
||||
permissions = {
|
||||
"customers": {"view_customerprofile", "add_customerprofile", "change_customerprofile", "view_contactgroup", "add_contactgroup", "change_contactgroup"},
|
||||
"tickets": {"view_ticket", "dispatch_group_tickets"},
|
||||
"notifications": {"view_smstemplate", "add_smstemplate", "change_smstemplate"},
|
||||
"accounts": {"view_ticketmagiclink"},
|
||||
}
|
||||
query = None
|
||||
from django.db.models import Q
|
||||
for app_label, codenames in permissions.items():
|
||||
part = Q(content_type__app_label=app_label, codename__in=codenames)
|
||||
query = part if query is None else query | part
|
||||
unit.permissions.add(*Permission.objects.filter(query))
|
||||
staff = User.objects.create_user(username="e2e-operations", password=PASSWORD, account_type=User.AccountType.STAFF)
|
||||
StaffProfile.objects.create(user=staff, display_name="کارشناس عملیات", primary_unit=unit)
|
||||
customer = User.objects.create_user(username="e2e-carrier", mobile="09120009121", account_type=User.AccountType.CUSTOMER)
|
||||
profile = CustomerProfile.objects.create(user=customer, first_name="باربری", stakeholder_type=CustomerProfile.StakeholderType.CARRIER)
|
||||
group = ContactGroup.objects.create(unit=unit, name="باربریها")
|
||||
profile.groups.add(group)
|
||||
category = TicketCategory.objects.create(title="استعلام E2E", slug="operations-e2e")
|
||||
ticket = create_ticket(customer=customer, created_by=staff, unit=unit, category=category, subject="استعلام آزمایشی", body="قیمت را اعلام کنید")
|
||||
TicketMagicLink.issue_with_record(ticket, created_by=staff)
|
||||
|
||||
with browser_page() as page:
|
||||
page.goto(f"{live_server.url}{reverse('accounts:login')}", wait_until="domcontentloaded")
|
||||
expect(page.locator(".auth-brand strong")).to_have_count(0)
|
||||
expect(page.locator(".auth-intro .eyebrow")).to_have_text("معرفی ورود E2E")
|
||||
expect(page.locator(".auth-footer")).to_contain_text("توسعه E2E")
|
||||
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
|
||||
for route, heading in (
|
||||
("staff_tickets:contacts", "مخاطبان و مشتریان"),
|
||||
("staff_tickets:groups", "گروههای مخاطبان"),
|
||||
("staff_tickets:contact_create", "ثبت مخاطب بیرونی"),
|
||||
("staff_tickets:sms_templates", "الگوهای پیامک واحدها"),
|
||||
("staff_tickets:dispatch_create", "ارسال گروهی ایزوله"),
|
||||
("staff_tickets:magic_audit", "ممیزی لینکهای جادویی"),
|
||||
):
|
||||
page.goto(f"{live_server.url}{reverse(route)}", wait_until="domcontentloaded")
|
||||
expect(page.get_by_role("heading", name=heading, level=1)).to_be_visible()
|
||||
expect(page.locator('link[href*="operations.css"]')).to_have_count(1)
|
||||
assert page.evaluate("getComputedStyle(document.querySelector('.operations-page')).display") == "grid"
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_create')}", wait_until="domcontentloaded")
|
||||
expect(page).to_have_title("ردیابی E2E")
|
||||
expect(page.locator(".staff-brand-copy b")).to_have_text("مرکز عملیات E2E")
|
||||
expect(page.locator(".staff-developer-credit")).to_contain_text("امضای مستقل sidebar E2E")
|
||||
expect(page.get_by_role("searchbox", name="جستجو")).to_be_visible()
|
||||
expect(page.locator('link[href*="dispatches.css"]')).to_have_count(1)
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:sms_templates')}", wait_until="domcontentloaded")
|
||||
expect(page.get_by_text("الگوی عمومی سیستم").first).to_be_visible()
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('staff_tickets:contact_create')}", wait_until="domcontentloaded")
|
||||
province = page.locator('select[name="province"]')
|
||||
city = page.locator('select[name="city"]')
|
||||
expect(province.locator("option")).not_to_have_count(1)
|
||||
province.select_option(label="اصفهان")
|
||||
expect(city.locator("option", has_text="کاشان")).to_have_count(1)
|
||||
Reference in new issue
Block a user