voip announcement
This commit is contained in:
1 parent
38b1cf06df
commit
c7993aae44
18 files changed
+679
-151
No files matched your search
@@ -0,0 +1,224 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.core.admin import VoipConfigurationAdmin
|
||||
from apps.core.models import VoipConfiguration
|
||||
from apps.messaging.models import MessageReadReceipt
|
||||
from apps.notifications.models import AnnouncementJob
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket, reply_ticket
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def announcement_catalog(db):
|
||||
unit = OrganizationalUnit.objects.create(name="واحد تماس", slug="announcement-unit")
|
||||
category = TicketCategory.objects.create(unit=unit, title="پیگیری", slug="announcement-category")
|
||||
return category
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def announcement_people(db):
|
||||
staff = User.objects.create_superuser(username="announcement-staff", password="safe-password", account_type="STAFF")
|
||||
customer = User.objects.create_user(username="announcement-customer", mobile="09120000000", account_type="CUSTOMER")
|
||||
return staff, customer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def announcement_data(announcement_catalog, announcement_people):
|
||||
configuration = VoipConfiguration.objects.create(
|
||||
announcement_url="http://192.168.138.215:8000/v1/announcements",
|
||||
announcement_id="custom/fereshte1_G711",
|
||||
announcement_delay_minutes=90,
|
||||
)
|
||||
return announcement_catalog, *announcement_people, configuration
|
||||
|
||||
|
||||
def due(job):
|
||||
AnnouncementJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now() - timedelta(seconds=1))
|
||||
|
||||
|
||||
def test_staff_creation_and_each_public_reply_queue_independent_calls(announcement_data):
|
||||
category, staff, customer, _ = announcement_data
|
||||
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
|
||||
first = ticket.ticket_messages.get()
|
||||
first_job = AnnouncementJob.objects.get(message=first)
|
||||
assert timedelta(minutes=89) <= first_job.next_attempt_at - first.created_at <= timedelta(minutes=91)
|
||||
reply = reply_ticket(ticket_id=ticket.pk, user=staff, body="پاسخ تازه")
|
||||
assert AnnouncementJob.objects.filter(message__ticket=ticket).count() == 2
|
||||
assert AnnouncementJob.objects.get(message=reply).idempotency_key != first_job.idempotency_key
|
||||
reply_ticket(ticket_id=ticket.pk, user=staff, body="یادداشت داخلی", internal=True)
|
||||
reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ مشتری")
|
||||
assert AnnouncementJob.objects.filter(message__ticket=ticket).count() == 2
|
||||
customer_ticket = create_ticket(customer=customer, category=category, subject="درخواست مشتری", body="پیام مشتری")
|
||||
assert not AnnouncementJob.objects.filter(message__ticket=customer_ticket).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_key", ["", "secret-key"])
|
||||
def test_due_unread_message_calls_once_with_optional_api_key(announcement_data, api_key):
|
||||
category, staff, customer, configuration = announcement_data
|
||||
configuration.announcement_api_key = api_key
|
||||
configuration.save()
|
||||
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
|
||||
job = AnnouncementJob.objects.get(message__ticket=ticket)
|
||||
response = Mock(content=b'{}')
|
||||
response.json.return_value = {}
|
||||
with patch("apps.integrations.announcements.requests.post", return_value=response) as post:
|
||||
call_command("process_notifications", once=True)
|
||||
assert not post.called
|
||||
due(job)
|
||||
call_command("process_notifications", once=True)
|
||||
call_command("process_notifications", once=True)
|
||||
job.refresh_from_db()
|
||||
assert job.status == AnnouncementJob.Status.SENT and job.attempts == 1
|
||||
post.assert_called_once()
|
||||
assert post.call_args.args == (configuration.announcement_url,)
|
||||
assert post.call_args.kwargs["json"] == {"customer_number": "09120000000", "announcement_id": configuration.announcement_id}
|
||||
assert post.call_args.kwargs["headers"] == ({"Idempotency-Key": job.idempotency_key, "X-API-Key": api_key} if api_key else {"Idempotency-Key": job.idempotency_key})
|
||||
|
||||
|
||||
def test_customer_read_cancels_only_that_message(announcement_data):
|
||||
category, staff, customer, _ = announcement_data
|
||||
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
|
||||
second = reply_ticket(ticket_id=ticket.pk, user=staff, body="پاسخ دوم")
|
||||
first_job = AnnouncementJob.objects.get(message=ticket.ticket_messages.exclude(pk=second.pk).get())
|
||||
second_job = AnnouncementJob.objects.get(message=second)
|
||||
MessageReadReceipt.objects.create(message=first_job.message, user=customer)
|
||||
due(first_job)
|
||||
due(second_job)
|
||||
with patch("apps.integrations.announcements.requests.post", return_value=Mock(content=b"", success=True)) as post:
|
||||
call_command("process_notifications", once=True)
|
||||
first_job.refresh_from_db()
|
||||
second_job.refresh_from_db()
|
||||
assert first_job.status == AnnouncementJob.Status.CANCELLED and first_job.last_error == "customer_read"
|
||||
assert second_job.status == AnnouncementJob.Status.SENT
|
||||
post.assert_called_once()
|
||||
|
||||
|
||||
def test_provider_failure_retries_with_same_idempotency_key(announcement_data):
|
||||
category, staff, customer, _ = announcement_data
|
||||
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
|
||||
job = AnnouncementJob.objects.get(message__ticket=ticket)
|
||||
keys = []
|
||||
def fail(*args, **kwargs):
|
||||
keys.append(kwargs["headers"]["Idempotency-Key"])
|
||||
raise requests.Timeout("timeout")
|
||||
with patch("apps.integrations.announcements.requests.post", side_effect=fail):
|
||||
for attempt in range(3):
|
||||
due(job)
|
||||
call_command("process_notifications", once=True)
|
||||
job.refresh_from_db()
|
||||
assert job.attempts == attempt + 1
|
||||
assert job.status == AnnouncementJob.Status.FAILED
|
||||
assert keys == [job.idempotency_key] * 3
|
||||
assert job.last_error == "service_timeout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider_result", "expected_error"),
|
||||
[
|
||||
(requests.ConnectionError("secret-in-provider-error"), "service_unreachable"),
|
||||
(requests.HTTPError("503 Service Unavailable"), "service_http_error"),
|
||||
(Mock(content=b"not-json", json=Mock(side_effect=ValueError("bad JSON"))), "invalid_service_response"),
|
||||
(Mock(content=b"[]", json=Mock(return_value=[])), "invalid_service_response"),
|
||||
(Mock(content=b'{"success": false}', json=Mock(return_value={"success": False})), "service_rejected"),
|
||||
],
|
||||
)
|
||||
def test_provider_errors_are_sanitized_and_do_not_stop_worker(announcement_data, provider_result, expected_error):
|
||||
category, staff, customer, _ = announcement_data
|
||||
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
|
||||
second = reply_ticket(ticket_id=ticket.pk, user=staff, body="پیام دوم")
|
||||
first_job = AnnouncementJob.objects.get(message=ticket.ticket_messages.exclude(pk=second.pk).get())
|
||||
second_job = AnnouncementJob.objects.get(message=second)
|
||||
due(first_job)
|
||||
due(second_job)
|
||||
success = Mock(content=b"")
|
||||
with patch("apps.integrations.announcements.requests.post", side_effect=[provider_result, success]) as post:
|
||||
call_command("process_notifications", once=True)
|
||||
first_job.refresh_from_db()
|
||||
second_job.refresh_from_db()
|
||||
assert post.call_count == 2
|
||||
assert first_job.status == AnnouncementJob.Status.PENDING
|
||||
assert first_job.attempts == 1 and first_job.last_error == expected_error
|
||||
assert second_job.status == AnnouncementJob.Status.SENT
|
||||
assert first_job.next_attempt_at > timezone.now()
|
||||
|
||||
|
||||
def test_customer_reply_prevents_unnecessary_call(announcement_data):
|
||||
category, staff, customer, _ = announcement_data
|
||||
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
|
||||
job = AnnouncementJob.objects.get(message__ticket=ticket)
|
||||
reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ مشتری")
|
||||
due(job)
|
||||
with patch("apps.integrations.announcements.requests.post") as post:
|
||||
call_command("process_notifications", once=True)
|
||||
job.refresh_from_db()
|
||||
assert job.status == AnnouncementJob.Status.CANCELLED and job.last_error == "customer_replied"
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_missing_configuration_cancels_without_call(announcement_data):
|
||||
category, staff, customer, configuration = announcement_data
|
||||
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
|
||||
job = AnnouncementJob.objects.get(message__ticket=ticket)
|
||||
configuration.announcement_url = ""
|
||||
configuration.save()
|
||||
due(job)
|
||||
with patch("apps.integrations.announcements.requests.post") as post:
|
||||
call_command("process_notifications", once=True)
|
||||
job.refresh_from_db()
|
||||
assert job.status == AnnouncementJob.Status.CANCELLED and job.last_error == "announcement_not_configured"
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_database_delay_and_admin_api_key_controls(announcement_data):
|
||||
category, staff, customer, configuration = announcement_data
|
||||
configuration.announcement_delay_minutes = 15
|
||||
configuration.announcement_api_key = "stored-secret"
|
||||
configuration.save()
|
||||
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
|
||||
job = AnnouncementJob.objects.get(message__ticket=ticket)
|
||||
assert timedelta(minutes=14) <= job.next_attempt_at - job.message.created_at <= timedelta(minutes=16)
|
||||
|
||||
form_type = VoipConfigurationAdmin.ConfigurationForm
|
||||
data = {
|
||||
"announcement_url": configuration.announcement_url,
|
||||
"announcement_id": configuration.announcement_id,
|
||||
"announcement_delay_minutes": 15,
|
||||
"voip_call_url": "",
|
||||
}
|
||||
unchanged = form_type(data=data, instance=configuration)
|
||||
assert "stored-secret" not in unchanged.as_p()
|
||||
assert unchanged.is_valid(), unchanged.errors
|
||||
assert unchanged.save().announcement_api_key == "stored-secret"
|
||||
cleared = form_type(data={**data, "clear_announcement_api_key": "on"}, instance=configuration)
|
||||
assert cleared.is_valid(), cleared.errors
|
||||
assert cleared.save().announcement_api_key == ""
|
||||
|
||||
|
||||
def test_staff_ticket_shows_call_history_without_claiming_connection(client, announcement_data):
|
||||
category, staff, customer, configuration = announcement_data
|
||||
configuration.announcement_api_key = "private-provider-key"
|
||||
configuration.save()
|
||||
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
|
||||
reply = reply_ticket(ticket_id=ticket.pk, user=staff, body="پاسخ تازه")
|
||||
first_job = AnnouncementJob.objects.get(message=ticket.ticket_messages.exclude(pk=reply.pk).get())
|
||||
second_job = AnnouncementJob.objects.get(message=reply)
|
||||
AnnouncementJob.objects.filter(pk=first_job.pk).update(status=AnnouncementJob.Status.FAILED, attempts=3, last_error="service_timeout")
|
||||
AnnouncementJob.objects.filter(pk=second_job.pk).update(status=AnnouncementJob.Status.SENT, attempts=1, sent_at=timezone.now())
|
||||
client.force_login(staff)
|
||||
content = client.get(f"/staff/tickets/{ticket.public_id}/").content.decode()
|
||||
assert "وضعیت پیامکهای مشتری" in content
|
||||
assert "وضعیت اطلاعرسانیهای تلفنی" in content
|
||||
assert content.count('aria-label="نمایش جزئیات اطلاعرسانی تلفنی"') == 2
|
||||
assert "سرویس تماس در مهلت مقرر پاسخ نداد." in content
|
||||
assert "درخواست تماس پذیرفته شد" in content
|
||||
assert "پذیرش درخواست، بهتنهایی برقرار شدن" in content
|
||||
assert configuration.announcement_url not in content
|
||||
assert configuration.announcement_api_key not in content
|
||||
@@ -132,3 +132,35 @@ def test_voip_error_is_minimal(client, voip_data):
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("provider_error", [requests.Timeout("timed out"), requests.HTTPError("503 unavailable")])
|
||||
def test_voip_provider_failure_does_not_raise_into_staff_page(client, voip_data, provider_error):
|
||||
agent, customer, _ = voip_data
|
||||
client.force_login(agent)
|
||||
with patch("apps.integrations.voip.requests.post", side_effect=provider_error) as post:
|
||||
response = client.post(
|
||||
reverse("staff_tickets:contact_call", args=[customer.public_id]),
|
||||
data=json.dumps({"method": "mobile"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {"success": False, "message": "برقراری تماس ناموفق بود."}
|
||||
assert post.call_args.kwargs["timeout"] > 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_voip_invalid_provider_response_is_reported_as_failure(client, voip_data):
|
||||
agent, customer, _ = voip_data
|
||||
client.force_login(agent)
|
||||
provider_response = Mock(content=b"[]")
|
||||
provider_response.json.return_value = []
|
||||
with patch("apps.integrations.voip.requests.post", return_value=provider_response):
|
||||
response = client.post(
|
||||
reverse("staff_tickets:contact_call", args=[customer.public_id]),
|
||||
data=json.dumps({"method": "mobile"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {"success": False, "message": "برقراری تماس ناموفق بود."}
|
||||
Reference in new issue
Block a user