fix payement link and gallery image

This commit is contained in:
javad committed 2026-10-06 11:18:31 +03:30
1 parent de3135b588
commit 1cbbbbd0dc
24 files changed
+654 -116

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
from playwright.sync_api import expect
from django.urls import reverse
from .base import BasePage
class MessageReadsPage(BasePage):
def acknowledgement(self, read_path, message):
return self.page.expect_response(lambda response: response.request.method == 'POST'
and response.url.endswith(read_path)
and str(message.public_id) in (response.request.post_data_json or {}).get('message_ids', []))
def open_and_acknowledge(self, path, read_path, message):
with self.acknowledgement(read_path, message):
self.goto(path)
expect(self.message(message)).to_be_in_viewport()
def message(self, message):
return self.page.locator(f'[data-message-id="{message.public_id}"]')
def stop_initial_follow(self):
self.page.locator('[data-conversation]').hover()
self.page.mouse.wheel(0, -1)
def scroll_and_acknowledge(self, message, read_path):
self.stop_initial_follow()
with self.acknowledgement(read_path, message):
self.message(message).scroll_into_view_if_needed()
expect(self.message(message)).to_be_in_viewport()
def expect_unseen(self, message):
expect(self.message(message).locator('.message-receipts')).to_have_count(0)
def expect_seen_by(self, message, name):
self.stop_initial_follow()
self.message(message).locator('.message-receipts summary').click()
expect(self.message(message).locator('.message-receipts')).to_contain_text(name)
def emulate_background_tab(self):
# Headless Chromium can keep multiple pages visible; control this signal
# explicitly to test the same visibilitychange branch deterministically.
self.page.add_init_script("Object.defineProperty(document, 'visibilityState', {get: () => window.receiptVisibility || 'hidden'});")
def activate_and_acknowledge(self, message, read_path):
with self.acknowledgement(read_path, message):
self.page.evaluate("window.receiptVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange'));")
def open_in_background(self, path, poll_path):
with self.page.expect_response(lambda response: response.url.split('?')[0].endswith(poll_path)):
self.goto(path)
def fail_first_acknowledgement(self, read_path):
def fail(route):
route.fulfill(status=503, content_type='application/json', body='{}')
self.page.route('**' + read_path, fail, times=1)
def wait_for_successful_acknowledgement(self, read_path):
with self.page.expect_response(lambda response: response.url.endswith(read_path) and response.status == 200):
pass
class MessageReadsRacePage(MessageReadsPage):
def hold_poll_after_receiving_its_body(self):
self.page.add_init_script("""
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, options) => {
const url = new URL(input, location.href);
const polling = url.pathname.includes('/messages/poll/');
if (polling) { url.searchParams.delete('version'); url.searchParams.delete('cursor'); }
const response = await nativeFetch(polling ? url : input, options);
if (polling && response.ok && !window.blockedPoll) {
window.blockedPoll = true;
const json = response.json.bind(response);
response.json = async () => {
const data = await json();
await new Promise(resolve => { window.releaseHeldPoll = resolve; });
window.heldPollParsed = true;
return data;
};
}
return response;
};
""")
def revoke_ticket(self, ticket, recipient, session_cookie, csrf):
self.page.context.request.post(self.url(reverse('staff_tickets:revoke', args=[ticket.public_id, recipient.public_id])),
headers={'Cookie': session_cookie, 'X-CSRFToken': csrf}, max_redirects=0)
def scroll_to_revoked_message(self, message, read_path):
self.stop_initial_follow()
with self.page.expect_response(lambda response: response.url.endswith(read_path) and response.status == 403):
self.message(message).scroll_into_view_if_needed()
expect(self.page.get_by_text('دسترسی به این مکاتبات لغو شده است.', exact=True)).to_be_visible()
def release_poll_and_expect_no_messages(self):
self.page.wait_for_function('typeof window.releaseHeldPoll === "function"')
self.page.evaluate('window.releaseHeldPoll()')
self.page.wait_for_function('window.heldPollParsed === true')
self.page.evaluate('() => new Promise(requestAnimationFrame)')
expect(self.page.locator('[data-message-id]')).to_have_count(0)
expect(self.page.get_by_text('دسترسی به این مکاتبات لغو شده است.', exact=True)).to_be_visible()
+5 -1
View File
@@ -35,6 +35,10 @@ class PaymentLinkPage(PaymentsPage):
self.page.locator('#id_paid_amount').fill('۱۲۳۴۵')
expect(self.page.locator('#id_paid_amount')).to_have_value('۱۲٬۳۴۵')
self.page.get_by_role('button', name='امروز', exact=True).click()
self.page.locator('#id_receipt_image').set_input_files({'name': 'payment.png', 'mimeType': 'image/png', 'buffer': png})
with self.page.expect_file_chooser() as picker:
self.page.locator('label.customer-file-button').click()
picker.value.set_files({'name': 'screenshot.png', 'mimeType': 'image/png', 'buffer': png})
expect(self.page.locator('#receipt-file-status')).to_contain_text('screenshot.png')
expect(self.page.locator('#receipt-file-preview')).to_be_visible()
self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click()
expect(self.page.locator('.flash')).to_contain_text('فیش ثبت شد و در انتظار بررسی است.')
+108
View File
@@ -0,0 +1,108 @@
import pytest
from django.test import Client
from django.urls import reverse
from apps.customers.models import CustomerProfile
from apps.messaging.models import Message
from apps.tickets.sharing import share_staff_ticket
from tests.test_staff_sharing import make_sharing_case
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.message_reads import MessageReadsPage, MessageReadsRacePage
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
PASSWORD = 'SharingTest123!'
@pytest.fixture
def reading_case(settings):
settings.PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
case = make_sharing_case()
case['customer'].set_password(PASSWORD)
case['customer'].save(update_fields=['password'])
CustomerProfile.objects.create(user=case['customer'], first_name='مشتری', last_name='خواننده')
share_staff_ticket(case['ticket'], case['sender'], case['recipient'], 'VIEW_ONLY')
case['probes'] = [Message.objects.create(ticket=case['ticket'], author=case['sender'],
body=f'پیام آزمایشی {i}\n' + ('متن برای آزمایش محدوده مشاهده. ' * 45)) for i in range(12)]
return case
@pytest.mark.parametrize('staff,width', [(True, 1280), (False, 390)])
def test_only_on_screen_messages_receive_seen_and_scrolling_acknowledges_more(live_server, browser_page, reading_case, staff, width):
c = reading_case
namespace = 'staff_tickets' if staff else 'tickets'
detail = reverse(namespace + ':detail', args=[c['ticket'].public_id])
read = reverse(namespace + (':read_shares' if staff else ':read'), args=[c['ticket'].public_id])
reader_name = 'کارشناس recipient' if staff else 'مشتری خواننده'
oldest, latest = c['probes'][0], c['probes'][-1]
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
auth = AuthenticationPage(page, live_server.url)
if staff:
auth.login_staff(c['recipient'], PASSWORD)
else:
auth.login_customer(c['customer'], PASSWORD)
MessageReadsPage(page, live_server.url).open_and_acknowledge(detail, read, latest)
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(c['sender'], PASSWORD)
view = MessageReadsPage(page, live_server.url)
view.goto(reverse('staff_tickets:detail', args=[c['ticket'].public_id]))
view.expect_unseen(oldest)
view.expect_seen_by(latest, reader_name)
with browser_page() as page:
auth = AuthenticationPage(page, live_server.url)
if staff:
auth.login_staff(c['recipient'], PASSWORD)
else:
auth.login_customer(c['customer'], PASSWORD)
view = MessageReadsPage(page, live_server.url)
view.open_and_acknowledge(detail, read, latest)
view.scroll_and_acknowledge(oldest, read)
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(c['sender'], PASSWORD)
view = MessageReadsPage(page, live_server.url)
view.goto(reverse('staff_tickets:detail', args=[c['ticket'].public_id]))
view.expect_seen_by(oldest, reader_name)
def test_background_poll_does_not_mark_seen_and_failed_acknowledgement_retries(live_server, browser_page, reading_case):
c = reading_case
detail = reverse('staff_tickets:detail', args=[c['ticket'].public_id])
read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
poll = reverse('staff_tickets:poll', args=[c['ticket'].public_id])
latest = c['probes'][-1]
with browser_page() as page:
view = MessageReadsPage(page, live_server.url)
view.emulate_background_tab()
AuthenticationPage(page, live_server.url).login_staff(c['recipient'], PASSWORD)
view.open_in_background(detail, poll)
# A second server render exposes any erroneous background receipts.
view.open_in_background(detail, poll)
view.expect_unseen(latest)
view.fail_first_acknowledgement(read)
view.activate_and_acknowledge(latest, read)
# The first response is deliberately 503; the successful retry must arrive.
view.wait_for_successful_acknowledgement(read)
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(c['sender'], PASSWORD)
view = MessageReadsPage(page, live_server.url)
view.goto(detail)
view.expect_seen_by(latest, 'کارشناس recipient')
def test_revocation_wins_over_a_poll_response_already_in_flight(live_server, browser_page, reading_case):
c = reading_case
detail = reverse('staff_tickets:detail', args=[c['ticket'].public_id])
read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
sender = Client()
sender.force_login(c['sender'])
sender.get(detail)
csrf = sender.cookies['csrftoken'].value
cookie = f"sessionid={sender.cookies['sessionid'].value}; csrftoken={csrf}"
with browser_page() as reader:
view = MessageReadsRacePage(reader, live_server.url)
view.hold_poll_after_receiving_its_body()
AuthenticationPage(reader, live_server.url).login_staff(c['recipient'], PASSWORD)
view.open_and_acknowledge(detail, read, c['probes'][-1])
view.revoke_ticket(c['ticket'], c['recipient'], cookie, csrf)
view.scroll_to_revoked_message(c['probes'][0], read)
view.release_poll_and_expect_no_messages()
+153
View File
@@ -0,0 +1,153 @@
import json
import uuid
from datetime import timedelta
import pytest
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import TicketMagicLink
from apps.messaging.models import Message, MessageReadReceipt
from apps.tickets.models import TicketReadState
from apps.tickets.sharing import revoke_staff_share, share_staff_message, share_staff_ticket
from tests.test_staff_sharing import make_sharing_case
pytestmark = pytest.mark.django_db
@pytest.fixture
def seen_case(settings):
settings.PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
case = make_sharing_case()
case['reply'] = Message.objects.create(ticket=case['ticket'], author=case['sender'], body='پاسخ قابل مشاهده')
return case
@pytest.mark.parametrize('route', ['detail', 'messages', 'poll'])
def test_staff_get_requests_never_mark_messages_or_ticket_read(client, seen_case, route):
c = seen_case
share_staff_ticket(c['ticket'], c['sender'], c['recipient'], 'VIEW_ONLY')
client.force_login(c['recipient'])
response = client.get(reverse('staff_tickets:' + route, args=[c['ticket'].public_id]))
assert response.status_code == 200
assert not MessageReadReceipt.objects.filter(user=c['recipient']).exists()
assert not TicketReadState.objects.filter(user=c['recipient']).exists()
def test_staff_acknowledgement_is_scoped_idempotent_and_rechecked_after_revocation(client, seen_case):
c = seen_case
grant = share_staff_message(c['selected'], c['sender'], c['recipient'])
client.force_login(c['recipient'])
url = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
values = [str(c['selected'].public_id), str(c['reply'].public_id), str(uuid.uuid4()), 'bad-uuid']
for _ in range(2):
response = client.post(url, {'message_ids': values})
assert response.status_code == 200
assert response.json()['read_ids'] == [str(c['selected'].public_id)]
assert list(MessageReadReceipt.objects.filter(user=c['recipient']).values_list('message_id', flat=True)) == [c['selected'].pk]
assert not TicketReadState.objects.filter(user=c['recipient']).exists()
grant.refresh_from_db()
assert grant.view_count == 1
revoke_staff_share(grant, c['sender'])
assert client.post(url, {'message_ids': values}).status_code == 403
assert client.get(reverse('staff_tickets:poll', args=[c['ticket'].public_id])).status_code == 403
assert not MessageReadReceipt.objects.filter(message=c['reply'], user=c['recipient']).exists()
def test_staff_ticket_read_state_requires_acknowledging_the_latest_message(client, seen_case):
c = seen_case
client.force_login(c['sender'])
url = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
client.post(url, {'message_ids': [str(c['selected'].public_id)]})
assert not TicketReadState.objects.filter(user=c['sender']).exists()
client.post(url, {'message_ids': [str(c['reply'].public_id)]})
assert TicketReadState.objects.get(user=c['sender']).last_read_message_id == c['reply'].pk
assert not MessageReadReceipt.objects.filter(message=c['reply'], user=c['sender']).exists()
def test_another_colleagues_read_state_does_not_hide_my_unread_ticket(seen_case):
from apps.tickets.selectors import filter_staff_tickets, staff_ticket_rows
c = seen_case
share_staff_ticket(c['ticket'], c['sender'], c['recipient'], 'VIEW_ONLY')
TicketReadState.objects.create(ticket=c['ticket'], user=c['sender'], last_read_at=timezone.now(), last_read_message=c['reply'])
unread = filter_staff_tickets(staff_ticket_rows(c['recipient']), c['recipient'], {'unread': '1'})
assert unread.filter(pk=c['ticket'].pk).exists()
own_state = TicketReadState.objects.create(ticket=c['ticket'], user=c['recipient'], last_read_at=timezone.now(), last_read_message=c['reply'])
assert not unread.filter(pk=c['ticket'].pk).exists()
own_state.last_read_at = c['ticket'].last_activity_at - timedelta(seconds=1)
own_state.save(update_fields=['last_read_at'])
assert unread.filter(pk=c['ticket'].pk).exists()
def test_customer_receipts_exclude_own_deleted_internal_and_other_ticket_messages(client, seen_case):
c = seen_case
deleted = Message.objects.create(ticket=c['ticket'], author=c['sender'], body='حذف‌شده', deleted_at=timezone.now())
from apps.tickets.services import create_ticket
other = create_ticket(customer=c['customer'], category=c['ticket'].category, subject='تیکت دیگر', body='پیام تیکت دیگر')
foreign = Message.objects.create(ticket=other, author=c['sender'], body='پاسخ خصوصی تیکت دیگر')
client.force_login(c['customer'])
for route in ['detail', 'messages', 'poll']:
assert client.get(reverse('tickets:' + route, args=[c['ticket'].public_id])).status_code == 200
assert not MessageReadReceipt.objects.filter(user=c['customer']).exists()
identifiers = [str(value) for value in c['ticket'].ticket_messages.values_list('public_id', flat=True)]
identifiers += [str(foreign.public_id), str(uuid.uuid4()), 'not-a-uuid', 42, None]
url = reverse('tickets:read', args=[c['ticket'].public_id])
response = client.post(url, json.dumps({'message_ids': identifiers}), content_type='application/json')
assert response.status_code == 200
assert list(MessageReadReceipt.objects.filter(user=c['customer']).values_list('message_id', flat=True)) == [c['reply'].pk]
assert not MessageReadReceipt.objects.filter(message=deleted).exists()
@pytest.mark.parametrize('payload', ['not json', 'null', '[]', '{"message_ids":"bad"}', '{"message_ids":{}}'])
@pytest.mark.parametrize('staff', [True, False])
def test_malformed_read_payloads_return_400_without_recording_reads(client, seen_case, payload, staff):
c = seen_case
client.force_login(c['sender'] if staff else c['customer'])
url = reverse('staff_tickets:read_shares' if staff else 'tickets:read', args=[c['ticket'].public_id])
assert client.post(url, payload, content_type='application/json').status_code == 400
assert not MessageReadReceipt.objects.exists()
values = [str(c['reply'].public_id)] * 101
assert client.post(url, json.dumps({'message_ids': values}), content_type='application/json').status_code == 400
def test_read_acknowledgements_keep_csrf_and_magic_link_scope(seen_case):
c = seen_case
strict = Client(enforce_csrf_checks=True)
strict.force_login(c['sender'])
staff_url = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
assert strict.post(staff_url, {'message_ids': [str(c['selected'].public_id)]}).status_code == 403
anonymous = Client()
token = TicketMagicLink.issue(c['ticket'])
assert anonymous.get(reverse('accounts:magic', args=[token])).status_code == 302
url = reverse('tickets:read', args=[c['ticket'].public_id])
assert anonymous.post(url, json.dumps({'message_ids': [str(c['reply'].public_id)]}), content_type='application/json').status_code == 200
assert MessageReadReceipt.objects.filter(message=c['reply'], user=c['customer']).exists()
assert '_auth_user_id' not in anonymous.session
def test_polling_backlog_does_not_skip_messages_after_the_first_batch(client, seen_case):
c = seen_case
client.force_login(c['sender'])
url = reverse('staff_tickets:poll', args=[c['ticket'].public_id])
initial = client.get(url).json()
batch = [Message.objects.create(ticket=c['ticket'], author=c['customer'], body=f'پیام صف {i}') for i in range(55)]
first = client.get(url, {'version': initial['version'], 'cursor': initial['cursor']}).json()
assert not first['replace'] and first['cursor'] == str(batch[49].public_id)
second = client.get(url, {'version': first['version'], 'cursor': first['cursor']}).json()
assert not second['replace'] and second['cursor'] == str(batch[-1].public_id)
assert batch[-1].body in second['html']
assert not MessageReadReceipt.objects.exists()
def test_polling_detects_editing_an_older_message_even_with_a_new_reply(client, seen_case):
from apps.messaging.services import edit_message
c = seen_case
client.force_login(c['customer'])
url = reverse('tickets:poll', args=[c['ticket'].public_id])
initial = client.get(url).json()
edit_message(c['selected'].pk, c['customer'], 'متن اصلاح‌شده پیام قبلی')
Message.objects.create(ticket=c['ticket'], author=c['sender'], body='پاسخ همزمان')
response = client.get(url, {'version': initial['version'], 'cursor': initial['cursor']}).json()
assert response['changed'] and response['replace']
assert 'متن اصلاح‌شده پیام قبلی' in response['html'] and 'پاسخ همزمان' in response['html']
+51 -1
View File
@@ -1,5 +1,6 @@
"""Permanent regressions for SMS payment capabilities; no real SMS is sent."""
import uuid
import re
from datetime import timedelta
from urllib.parse import urlsplit
@@ -10,7 +11,7 @@ from django.test import Client
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import User
from apps.accounts.models import TicketMagicLink, User
from apps.core.models import SiteBranding
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.payments import services
@@ -40,6 +41,9 @@ def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settin
notice_sms = PaymentNotification.objects.get(job=job)
url = notice_sms.context['payment_link']
token = urlsplit(url).path.rstrip('/').rsplit('/', 1)[-1]
assert urlsplit(url).path == f'/p/{token}/'
assert re.fullmatch(r'[a-z0-9]{10}', token)
assert url in notice_sms.rendered_body
assert notice_sms.access_link.token_hash == token_digest(token)
assert notice_sms.access_link.expires_at > timezone.now() + timedelta(days=2)
first_body = notice_sms.rendered_body
@@ -55,6 +59,52 @@ def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settin
assert notice_sms.access_link.view_count == 2
def test_short_payment_link_retries_collision_without_breaking_transaction(case, client, monkeypatch):
tokens = iter(['ac26i9raoz', 'ac26i9raoz', 'lfp694raup'])
monkeypatch.setattr(TicketMagicLink, 'generate_secure_token', lambda length: next(tokens))
first, _ = issue_payment_link(notice=case['notice'])
second, url = issue_payment_link(notice=case['notice'])
assert first.pk != second.pk
assert urlsplit(url).path == '/p/lfp694raup/'
assert PaymentAccessLink.objects.count() == 2
assert client.get('/p/lfp694raup/', follow=True).status_code == 200
def test_payment_link_collision_retries_are_bounded(case, monkeypatch):
monkeypatch.setattr(TicketMagicLink, 'generate_secure_token', lambda length: 'ac26i9raoz')
issue_payment_link(notice=case['notice'])
with pytest.raises(RuntimeError, match='لینک یکتای پرداخت'):
issue_payment_link(notice=case['notice'])
assert PaymentAccessLink.objects.count() == 1
def test_legacy_long_payment_link_keeps_scope_and_expiry(case, client):
token = 'x_NAEA8ptOXu5msNYgNNpCM8vyWh6_VAhELPs5q8C2U'
link = PaymentAccessLink.objects.create(customer=case['customer'], notice=case['notice'],
token_hash=token_digest(token), expires_at=timezone.now() + timedelta(days=1))
path = reverse('payments:open_link', args=[token])
response = client.get(path, follow=True)
assert response.status_code == 200
assert client.session[SESSION_KEY] == link.pk
assert '_auth_user_id' not in client.session
PaymentAccessLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1))
assert client.get(path).status_code == 410
@pytest.mark.parametrize('via_link', [False, True])
def test_receipt_picker_accepts_gallery_images_without_camera_capture(case, client, via_link):
if via_link:
open_notice(client, case)
else:
client.force_login(case['customer'])
response = client.get(reverse('payments:receipt_new'), {'notice': case['notice'].public_id})
assert response.status_code == 200
widget = response.context['form'].fields['receipt_image'].widget
assert widget.attrs['accept'] == 'image/*'
assert 'capture' not in widget.attrs
assert 'accept="image/*"' in response.content.decode()
def test_public_notice_preserves_copy_warning_and_database_signature(case, client):
branding = SiteBranding.load()
branding.developer_credit_text = 'امضای توسعه‌دهنده از دیتابیس'
+5 -1
View File
@@ -206,6 +206,7 @@ def test_missing_policy_stays_unapproved_until_manager_assigns(case):
def test_sms_uses_database_template_and_attempt_history(case,monkeypatch):
from apps.notifications.management.commands import process_notifications
from apps.payments.models import PaymentNotification
template=SMSTemplate.objects.get(event_type='PAYMENT_NOTICE_CREATED',unit__isnull=True)
template.body='متن سفارشی {notice_number} {payment_link}';template.save()
captured=[]
@@ -217,7 +218,10 @@ def test_sms_uses_database_template_and_attempt_history(case,monkeypatch):
process_notifications._send_sms(job)
template.body='جدید';template.save()
process_notifications._send_sms(job)
assert captured[0] == captured[1] and 'متن سفارشی' in captured[0] and '/chaharsu/' in captured[0]
notification = PaymentNotification.objects.get(job=job)
assert captured[0] == captured[1] and 'متن سفارشی' in captured[0]
assert '/p/' in notification.context['payment_link']
assert notification.context['payment_link'] in captured[0]
assert PaymentNotificationAttempt.objects.filter(success=True).count() == 2
assert job.ticket is None
+4 -1
View File
@@ -490,7 +490,10 @@ def test_ticket_polling_javascript_keeps_background_sync_and_recovers_reliably()
assert "conversationEnd?.scrollIntoView" not in script
assert "scroller.scrollTo({top,behavior:'smooth'})" in script
assert "data-message-id" in Path("templates/tickets/_messages.html").read_text(encoding="utf-8")
assert "acknowledgeVisibleMessages" in script and "document.visibilityState!=='visible'" in script
read_script=Path('static/js/conversation-read.js').read_text(encoding='utf-8')
assert "document.visibilityState!=='visible'" in read_script and 'getBoundingClientRect' in read_script and 'elementFromPoint' in read_script
for template in ('customer_detail.html', 'staff_detail.html'):
assert "{% static 'js/conversation-read.js' %}?v={{ static_asset_version }}" in Path('templates/tickets',template).read_text(encoding='utf-8')
source_css=Path("static/src/tailwind.css").read_text(encoding="utf-8")
assert ".staff-ticket-workspace .composer.is-collapsed{position:fixed" not in source_css
assert ".staff-ticket-workspace .composer:not(.is-collapsed){position:fixed" not in source_css