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']