Files
chatballs/apps/backend/chatballs/conversations/tests.py
T
AndreyandClaude Opus 5 482fa73d95 🚨 fix(lint): привести код к своей же конфигурации ruff и включить её в CI
Линтер никогда не проверял то, что задумано. Конфигурация лежит в корневом
pyproject.toml (`select = ["E","F","I","UP","B","DJ"]`), а запускали ruff из
apps/backend — там конфигурации нет, и он молча брал правила по умолчанию.
Из корня та же команда показывает 1102 нарушения вместо четырнадцати.

Что с ними сделано:

- E501 (968 из 1102) вынесен в ignore с объяснением: длину строки правит
  форматтер, а `ruff format` в этом репозитории не принят — он переписал бы
  374 файла. Порога, до которого можно поднять line-length, нет: 398 строк
  длиннее 120 символов, 222 длиннее 140. Правило, которое всегда красное,
  просто перестают читать.
- Миграции исключены из проверки: их пишет Django.
- Остальные 104 исправлены. Содержательное: assertRaises(Exception) в тесте
  доставки приглашения заменён на конкретный CallInviteDeliveryError; убрана
  мёртвая переменная identity в демо-сиде (у Conversation нет такого поля —
  единственный источник identity это контакт); порядок членов у пяти моделей
  по Django Style Guide; __str__ двум синглтонам инстанса, которые в админке
  показывались бы как «object (1)»; isinstance с кортежем → оператор |.

CI: ruff добавлен в .github/workflows/checks.yml (push и pull request — до
этого на GitHub не проверялось ничего до первого тега), в стадию validate
GitLab и в scripts/check.ps1. Везде запуск из корня репозитория, иначе
повторится та же подмена правил. Версия берётся из requirements бэкенда,
чтобы CI и образ не разъезжались.

Из pre-commit убран ruff-format: висел хук, который при первой же установке
переписал бы 374 файла — это отдельное решение, а не побочный эффект.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:39:18 +03:00

459 lines
21 KiB
Python

import json
from unittest import mock
from django.test import TestCase, override_settings
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider.base import ProviderError
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ControlMode,
Conversation,
ExpectedResponder,
Message,
MessageAuthor,
MessageKind,
)
from chatballs.conversations.transports import max as max_transport
from chatballs.conversations.transports import telegram as telegram_transport
from chatballs.conversations.transports.base import InboundMessage
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.notifications.models import Notification, NotificationAudience, NotificationType
from chatballs.testing import TenantAPIClient as APIClient
def _messenger_connection(channel):
return Integration.objects.create(
organization=channel.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="test-bot",
channel=channel,
)
class IngestLimitHandlingTests(TestCase):
"""При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен
«зависать»: его передают оператору с fallback-ответом клиенту (как при сбое
провайдера). См. ingest.ingest_inbound.
"""
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.channel = Channel.objects.create(organization=self.organization, code="app-sales", name="Acme — продажи")
AIAgent.objects.create(
channel=self.channel,
name="Acme Agent",
model="openai/gpt-4o-mini",
status=AIAgentStatus.ACTIVE,
)
self.integration = _messenger_connection(self.channel)
self.inbound = InboundMessage(
external_id="ext-1", user_id="user-1", chat_id="chat-1", text="Здравствуйте", display_name="Гость"
)
def test_limit_exceeded_hands_off_to_operator(self) -> None:
from chatballs.conversations.ingest import ingest_inbound
with (
mock.patch(
"chatballs.conversations.ingest.run_channel_turn",
side_effect=LimitExceeded("Channel daily AI cost limit reached"),
),
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
):
ingest_inbound(self.integration, self.inbound)
conversation = self.channel.conversations.get()
# Диалог передан оператору, ответчик — оператор.
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
self.assertEqual(conversation.expected_responder, ExpectedResponder.OPERATOR)
# Сохранены: входящее сообщение, системная отметка и fallback ИИ.
authors = list(self.channel.conversations.get().messages.values_list("author_type", flat=True))
self.assertIn(MessageAuthor.CONTACT, authors)
self.assertIn(MessageAuthor.SYSTEM, authors)
self.assertIn(MessageAuthor.AI, authors)
# Fallback отправлен в канал.
send.assert_called_once()
self.assertIn("специалисту", send.call_args.kwargs["text"])
class TransportNormalizeTests(TestCase):
"""Разбор входящих: username отправителя и явный шаринг контакта."""
def test_telegram_text_message_carries_username(self) -> None:
update = {
"update_id": 10,
"message": {
"text": "Привет",
"from": {"id": 5, "first_name": "Иван", "last_name": "Петров", "username": "ivan_petrov"},
"chat": {"id": 7},
},
}
inbound = telegram_transport._normalize(update)
self.assertIsNotNone(inbound)
self.assertEqual(inbound.username, "ivan_petrov")
self.assertEqual(inbound.display_name, "Иван Петров")
self.assertEqual(inbound.phone, "")
def test_telegram_contact_message_without_text(self) -> None:
update = {
"update_id": 11,
"message": {
"contact": {"phone_number": "+79991234567", "user_id": 5},
"from": {"id": 5, "first_name": "Иван", "username": "ivan_petrov"},
"chat": {"id": 7},
},
}
inbound = telegram_transport._normalize(update)
self.assertIsNotNone(inbound)
self.assertEqual(inbound.phone, "+79991234567")
self.assertEqual(inbound.text, "")
def test_max_contact_attachment_with_vcf(self) -> None:
update = {
"update_type": "message_created",
"message": {
"sender": {"user_id": 42, "name": "Мария", "username": "maria"},
"recipient": {"chat_id": 100},
"body": {
"mid": "m-1",
"attachments": [
{"type": "contact", "payload": {"vcf_info": "BEGIN:VCARD\nTEL;TYPE=CELL:+7 999 111-22-33\nEND:VCARD"}}
],
},
},
}
inbound = max_transport._normalize(update)
self.assertIsNotNone(inbound)
self.assertEqual(inbound.phone, "+7 999 111-22-33")
self.assertEqual(inbound.username, "maria")
def test_max_bot_started_becomes_start_command_with_payload(self) -> None:
update = {
"update_type": "bot_started",
"timestamp": 1573226679188,
"chat_id": 555,
"user": {"user_id": 42, "name": "Иван", "username": "ivan"},
"payload": "bind-code-123",
}
inbound = max_transport._normalize(update)
self.assertIsNotNone(inbound)
self.assertEqual(inbound.text, "/start bind-code-123")
self.assertEqual(inbound.chat_id, "555")
self.assertEqual(inbound.user_id, "42")
def test_max_text_message_without_contact(self) -> None:
update = {
"update_type": "message_created",
"message": {
"sender": {"user_id": 42, "name": "Мария"},
"recipient": {"chat_id": 100},
"body": {"mid": "m-2", "text": "Здравствуйте"},
},
}
inbound = max_transport._normalize(update)
self.assertIsNotNone(inbound)
self.assertEqual(inbound.phone, "")
self.assertEqual(inbound.text, "Здравствуйте")
class ContactShareIngestTests(TestCase):
"""Шаринг контакта: телефон сохраняется в Contact, AI-ход не запускается,
клиенту уходит подтверждение (в TG — со снятием клавиатуры)."""
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.channel = Channel.objects.create(organization=self.organization, code="app-sales", name="Acme — продажи")
AIAgent.objects.create(
channel=self.channel,
name="Acme Agent",
model="openai/gpt-4o-mini",
status=AIAgentStatus.ACTIVE,
)
self.integration = _messenger_connection(self.channel)
def test_username_saved_on_identity(self) -> None:
from chatballs.conversations.ingest import ingest_inbound
inbound = InboundMessage(
external_id="ext-1", user_id="u1", chat_id="c1", text="Привет", display_name="Иван", username="ivan"
)
with (
mock.patch("chatballs.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Здравствуйте!")),
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True),
):
ingest_inbound(self.integration, inbound)
identity = ConnectionIdentity.objects.get(connection=self.integration, external_user_id="u1")
self.assertEqual(identity.username, "ivan")
def test_contact_share_saves_phone_without_ai_turn(self) -> None:
from chatballs.conversations.ingest import ingest_inbound
inbound = InboundMessage(
external_id="ext-2", user_id="u1", chat_id="c1", text="", display_name="Иван", username="ivan", phone="+79991234567"
)
with (
mock.patch("chatballs.conversations.ingest.run_channel_turn") as ai_turn,
mock.patch("chatballs.conversations.ingest.transports.send_contact_ack", return_value=True) as ack,
):
ingest_inbound(self.integration, inbound)
ai_turn.assert_not_called()
ack.assert_called_once()
contact = ConnectionIdentity.objects.get(connection=self.integration, external_user_id="u1").contact
self.assertEqual(contact.phone, "+79991234567")
conversation = self.channel.conversations.get()
kinds = list(conversation.messages.values_list("kind", flat=True))
self.assertIn(MessageKind.CONTACT, kinds)
# Подтверждение сохранено в переписке.
self.assertTrue(conversation.messages.filter(author_type=MessageAuthor.AI, text__icontains="Контакт получен").exists())
class RequestContactApiTests(TestCase):
"""POST /conversations/<id>/request-contact/: сообщение kind=contact_request
и отправка кнопки в мессенджер; повторный запрос при известном телефоне — 409."""
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.channel = Channel.objects.create(organization=self.organization, code="app-sales", name="Acme — продажи")
self.integration = _messenger_connection(self.channel)
self.contact = Contact.objects.create(organization=self.organization, name="Иван")
ConnectionIdentity.objects.create(
contact=self.contact, connection=self.integration, external_user_id="u1", display_name="Иван"
)
self.conversation = Conversation.objects.create(
organization=self.organization, channel=self.channel, connection=self.integration,
contact=self.contact, external_chat_id="c1",
)
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def test_request_contact_creates_message_and_sends_button(self) -> None:
with mock.patch("chatballs.conversations.services.transports.send_contact_request", return_value=True) as send:
response = self.client.post(f"/api/v1/conversations/{self.conversation.id}/request-contact/")
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["message"]["kind"], MessageKind.CONTACT_REQUEST)
send.assert_called_once()
self.assertEqual(send.call_args.kwargs["chat_id"], "c1")
message = self.conversation.messages.get()
self.assertEqual(message.kind, MessageKind.CONTACT_REQUEST)
self.assertEqual(message.author_type, MessageAuthor.OPERATOR)
def test_request_contact_conflict_when_phone_known(self) -> None:
self.contact.phone = "+79991234567"
self.contact.save(update_fields=["phone"])
response = self.client.post(f"/api/v1/conversations/{self.conversation.id}/request-contact/")
self.assertEqual(response.status_code, 409)
def test_detail_payload_contains_username_and_phone(self) -> None:
ConnectionIdentity.objects.filter(contact=self.contact).update(username="ivan")
self.contact.phone = "+79991234567"
self.contact.save(update_fields=["phone"])
response = self.client.get(f"/api/v1/conversations/{self.conversation.id}/")
self.assertEqual(response.status_code, 200)
payload = response.json()["conversation"]["contact"]
self.assertEqual(payload["username"], "ivan")
self.assertEqual(payload["phone"], "+79991234567")
class ConversationReadTests(TestCase):
"""Открытие диалога двигает персональную отметку прочтения — бейдж
непрочитанных (pendingCount) в списке гаснет без ответа оператора."""
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.channel = Channel.objects.create(organization=self.organization, code="app-sales", name="Acme — продажи")
self.integration = _messenger_connection(self.channel)
contact = Contact.objects.create(organization=self.organization, name="Иван")
self.conversation = Conversation.objects.create(
organization=self.organization, channel=self.channel, connection=self.integration, contact=contact
)
Message.objects.create(conversation=self.conversation, author_type=MessageAuthor.CONTACT, text="Хорошо")
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def _pending(self) -> int:
items = self.client.get("/api/v1/conversations/").json()["items"]
return next(item["pendingCount"] for item in items if item["id"] == self.conversation.id)
def test_badge_clears_after_opening_dialog(self) -> None:
self.assertEqual(self._pending(), 1)
detail = self.client.get(f"/api/v1/conversations/{self.conversation.id}/")
self.assertEqual(detail.status_code, 200)
self.assertEqual(self._pending(), 0)
# Новое сообщение клиента снова поднимает бейдж.
Message.objects.create(conversation=self.conversation, author_type=MessageAuthor.CONTACT, text="Ещё вопрос")
self.assertEqual(self._pending(), 1)
class ConversationSearchTests(TestCase):
"""Поиск в списке диалогов: имена — подстрокой, тексты сообщений —
полнотекстово (russian, websearch)."""
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.channel = Channel.objects.create(organization=self.organization, code="line", name="Линия")
self.integration = _messenger_connection(self.channel)
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def _conversation(self, name: str, text: str) -> Conversation:
contact = Contact.objects.create(organization=self.organization, name=name)
conversation = Conversation.objects.create(
organization=self.organization, channel=self.channel, connection=self.integration, contact=contact
)
Message.objects.create(conversation=conversation, author_type=MessageAuthor.CONTACT, text=text)
return conversation
def _search(self, query: str) -> list[int]:
items = self.client.get(f"/api/v1/conversations/?q={query}").json()["items"]
return [item["id"] for item in items]
def test_fulltext_matches_message_words_with_morphology(self) -> None:
order = self._conversation("Мария", "Хочу оформить заказ на костюм")
self._conversation("Иван", "Когда работает шоурум?")
# Морфология: «заказы» находит «заказ».
self.assertEqual(self._search("заказы"), [order.id])
def test_contact_name_matches_by_substring(self) -> None:
maria = self._conversation("Мария Соколова", "Добрый день")
self._conversation("Иван", "Здравствуйте")
self.assertEqual(self._search("соколов"), [maria.id])
class WebchatContactTests(TestCase):
"""Веб-виджет: клиент отправляет телефон формой — он сохраняется в Contact,
в переписке появляются kind=contact и подтверждение."""
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.channel = Channel.objects.create(organization=self.organization, code="demo-web", name="Веб-чат")
AIAgent.objects.create(
channel=self.channel,
name="Web Agent",
model="openai/gpt-4o-mini",
status=AIAgentStatus.ACTIVE,
)
from chatballs.webchat.testing import create_web_widget
self.widget = create_web_widget(self.channel, name="web-widget")
self.client = APIClient()
session = self.client.post(
"/api/v1/webchat/session/",
data=json.dumps({"widgetKey": self.widget.public_key}),
content_type="application/json",
)
self.token = session.json()["token"]
def _post_contact(self, phone: str):
return self.client.post(
"/api/v1/webchat/contact/",
data=json.dumps({"phone": phone}),
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
def _post_message(self, text: str):
return self.client.post(
"/api/v1/webchat/messages/",
data=json.dumps({"text": text}),
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
def test_contact_saved_and_ack_visible_in_poll(self) -> None:
response = self._post_contact("+7 (999) 123-45-67")
self.assertEqual(response.status_code, 201)
contact = Contact.objects.exclude(phone="").get()
self.assertEqual(contact.phone, "+79991234567")
poll = self.client.get("/api/v1/webchat/messages/?since=0", HTTP_AUTHORIZATION=f"Bearer {self.token}")
kinds = [m["kind"] for m in poll.json()["messages"]]
self.assertIn(MessageKind.CONTACT, kinds)
def test_invalid_phone_rejected(self) -> None:
response = self._post_contact("12345")
self.assertEqual(response.status_code, 400)
@override_settings(CHATBALLS_AI_PROVIDER="")
def test_missing_provider_integration_hands_off_without_500_and_notifies_management(
self,
) -> None:
# Агент без BYOK-интеграции: IntegrationNotConfigured (ProviderError)
# переводит диалог оператору вместо 500 (ADR-CHATBALLS-0042 §3).
admin = HumanUser.objects.create_user(email="admin@example.com", password="temporary")
OrganizationMembership.objects.create(
user=admin,
organization=self.organization,
role=EmployeeRole.ADMIN,
position_title="Администратор",
)
response = self._post_message("Здравствуйте")
self.assertEqual(response.status_code, 201)
conversation = Conversation.objects.get(channel=self.channel)
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
self.assertEqual(conversation.expected_responder, ExpectedResponder.OPERATOR)
self.assertTrue(
conversation.messages.filter(
author_type=MessageAuthor.AI,
text__contains="специалисту",
).exists()
)
self.assertTrue(
Notification.objects.filter(
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
target_id=str(conversation.id),
).exists()
)
self.assertEqual(
set(
Notification.objects.filter(
type=NotificationType.INTEGRATION_ERROR,
audience=NotificationAudience.USER,
target_id=str(conversation.id),
).values_list("recipient_user__email", flat=True)
),
{"owner@example.com", "admin@example.com"},
)
def test_provider_error_hands_off_without_500(self) -> None:
with mock.patch(
"chatballs.conversations.ingest.run_channel_turn",
side_effect=ProviderError("AI недоступен"),
):
response = self._post_message("Здравствуйте")
self.assertEqual(response.status_code, 201)
conversation = Conversation.objects.get(channel=self.channel)
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
self.assertEqual(conversation.expected_responder, ExpectedResponder.OPERATOR)
self.assertTrue(
conversation.messages.filter(
author_type=MessageAuthor.AI,
text__contains="специалисту",
).exists()
)