fix payement link and gallery image
This commit is contained in:
1 parent
de3135b588
commit
1cbbbbd0dc
24 files changed
+654
-116
No files matched your search
@@ -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()
|
||||
@@ -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('فیش ثبت شد و در انتظار بررسی است.')
|
||||
@@ -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()
|
||||
@@ -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']
|
||||
@@ -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 = 'امضای توسعهدهنده از دیتابیس'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user