import io import re 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<>endobj\n" "2 0 obj<>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]") page.locator(".ticket-manual-routing summary").click() expect(form.unit).to_be_visible() 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_customer_wizard_validation_preview_edit_and_floating_submit( live_server, browser_page, public_workflow ): customer, units, categories = public_workflow subject = "بازبینی مرحله‌ای و ویرایش E2E" with browser_page() as page: page.set_viewport_size({"width": 390, "height": 640}) form = open_form(page, live_server, customer) expect(page.locator('[data-wizard-step="1"]')).to_be_visible() expect(page.locator('[data-wizard-step="2"]')).to_be_hidden() expect(page.locator('[data-ticket-search-results]')).to_be_hidden() page.locator('[data-wizard-step="1"] [data-wizard-next]').click() error = page.locator('[data-wizard-error="1"]') expect(error).to_be_visible() expect(error).to_be_in_viewport() expect(page.locator('[data-ticket-subject]')).to_have_attribute("aria-invalid", "true") expect(form.category).to_be_visible() assert page.evaluate("document.activeElement.matches('[aria-invalid=\"true\"]:not(:disabled)')") expect(page.locator(".is-guided-field")).to_have_count(1) form.select_search_result("عمومی فروش", "عمومی فروش") expect(page.locator('[data-ticket-subject]')).to_have_value("") page.locator('[data-wizard-step="1"] [data-wizard-next]').click() assert page.evaluate("document.activeElement === document.querySelector('[data-ticket-subject]')") expect(page.locator('.ticket-subject-field')).to_have_class(re.compile(r"is-guided-field")) page.locator('[data-ticket-subject]').fill(subject) expect(page.locator('[data-ticket-subject]')).to_have_value(subject) page.locator('[data-wizard-step="1"] [data-wizard-next]').click() page.locator('[data-wizard-step="2"] [data-wizard-next]').click() page.locator('[data-wizard-step="3"] [data-wizard-next]').click() page.locator('textarea[name="body"]').fill("شرح خوانا برای پیش‌نمایش مرحله پنجم") page.locator('[data-wizard-step="4"] [data-wizard-next]').click() expect(page.locator('[data-wizard-step="5"]')).to_be_visible() expect(page.locator('[data-review-subject]')).to_have_text(subject) expect(page.locator('[data-review-body]')).to_contain_text("شرح خوانا") final_submit = page.get_by_role("button", name="ثبت و ارسال نهایی") expect(final_submit).to_be_visible() submit_box = final_submit.bounding_box() assert submit_box and submit_box["y"] + submit_box["height"] <= page.viewport_size["height"] assert page.locator('[data-review-subject]').evaluate("node => parseFloat(getComputedStyle(node).fontSize)") >= 16 assert page.locator('[data-review-body]').evaluate("node => parseFloat(getComputedStyle(node).fontSize)") >= 14 page.locator('[data-wizard-edit="1"]').click() expect(page.locator('[data-ticket-subject]')).to_have_value(subject) expect(page.locator('[data-ticket-unit]')).to_have_value(str(units["sales"].pk)) expect(page.locator('[data-ticket-category]')).to_have_value(str(categories["sales_general"].pk)) def test_e2e_customer_wizard_auto_stops_voice_and_submits_audio_only_ticket( live_server, browser_page, public_workflow ): customer, units, categories = public_workflow subject = "ثبت تیکت فقط با پیام صوتی E2E" with browser_page() as page: page.add_init_script( """ window.__microphoneTrackStops = 0; Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: {getUserMedia: async () => ({getTracks: () => [{stop: () => {window.__microphoneTrackStops += 1;}}]})}, }); class FakeMediaRecorder { constructor(stream, options = {}) {this.stream = stream; this.mimeType = options.mimeType || 'audio/wav'; this.state = 'inactive';} start() {this.state = 'recording';} pause() {this.state = 'paused';} resume() {this.state = 'recording';} stop() { this.state = 'inactive'; const wav = new Uint8Array([ 82,73,70,70,40,0,0,0,87,65,86,69,102,109,116,32, 16,0,0,0,1,0,1,0,68,172,0,0,136,88,1,0, 2,0,16,0,100,97,116,97,4,0,0,0,0,0,0,0, ]); this.ondataavailable?.({data: new Blob([wav], {type: this.mimeType})}); setTimeout(() => this.onstop?.(), 0); } static isTypeSupported(type) {return type === 'audio/wav';} } Object.defineProperty(window, 'MediaRecorder', {configurable: true, value: FakeMediaRecorder}); """ ) form = open_form(page, live_server, customer) form.select_unit(units["sales"].pk) form.select_category(categories["sales_general"].pk) form.select_general_queue() page.locator('[data-ticket-subject]').fill(subject) page.locator('[data-wizard-step="1"] [data-wizard-next]').click() page.locator('[data-wizard-step="2"] [data-wizard-next]').click() page.locator('[data-wizard-step="3"] [data-wizard-next]').click() expect(page.locator('[data-wizard-step="4"]')).to_be_visible() page.get_by_role("button", name="شروع ضبط صدا").click() expect(page.locator('[data-voice-live]')).to_be_visible() page.get_by_role("button", name="مشاهده پیش‌نمایش").click() expect(page.locator('[data-wizard-step="5"]')).to_be_visible() expect(page.locator('[data-review-audio]')).to_be_visible() assert page.evaluate("window.__microphoneTrackStops") == 1 with page.expect_navigation(wait_until="domcontentloaded"): page.get_by_role("button", name="ثبت و ارسال نهایی").click() expect(page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$")) ticket = Ticket.objects.get(subject=subject) assert ticket.owning_unit == units["sales"] assert ticket.category == categories["sales_general"] attachments = list(ticket.ticket_messages.get().attachments.all()) assert len(attachments) == 1 assert attachments[0].mime_type.startswith("audio/") assert attachments[0].original_name.startswith("voice-") 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"], )