mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
🧹 chore!: продукт без привязок к вендору — домены, почта, продукты, скоупы, события виджета
- нет зашитых доменов: help-домен по умолчанию localhost (задаёт установщик через
CHATBALLS_HELP_BASE_DOMAIN), фронт определяет портал помощи пробой /api/v1/help/,
ALLOWED_HOSTS без вендорских хостов, CI environment url — из CHATBALLS_APP_DOMAIN
- DEFAULT_FROM_EMAIL по умолчанию no-reply@localhost; JWT-аудитория support-токена
chatballs.support; загрузчик виджета: window.ChatballsChat, события chatballs-chat-*
- «Контакты»: продукты берутся из данных организации ({code, name}), цвет —
детерминированно по коду; зашитый список продуктов вендора убран
- npm-скоупы @chatballs/*, тема chatballsTheme/buildTheme
- тестовый bootstrap: организация demo, сотрудник staff.member@example.org,
продукты site/app; фикстуры на example.com
- убрана мёртвая привязка Shell к e-mail сотрудника вендора
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a61780dec
commit
a891947016
85 files changed
+2632
-1560
No files matched your search
+1
-1
@@ -41,7 +41,7 @@ CHATBALLS_OPENROUTER_API_KEY=
|
||||
# EMAIL_HOST=
|
||||
# EMAIL_HOST_USER=
|
||||
# EMAIL_HOST_PASSWORD=
|
||||
# DEFAULT_FROM_EMAIL=Chatballs <no-reply@edevs.tech>
|
||||
# DEFAULT_FROM_EMAIL=Chatballs <no-reply@example.com>
|
||||
INTERNAL_UI_BASE_URL=http://localhost:5173
|
||||
|
||||
POSTGRES_DB=chatballs
|
||||
|
||||
@@ -40,7 +40,7 @@ https:// {
|
||||
}
|
||||
}
|
||||
|
||||
# Edevs platform surface. На C01 опубликован только health endpoint; API
|
||||
# Platform surface. На C01 опубликован только health endpoint; API
|
||||
# управления tenant'ами появится на C06.
|
||||
{$CHATBALLS_PLATFORM_DOMAIN} {
|
||||
encode gzip zstd
|
||||
|
||||
@@ -8,7 +8,7 @@ from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, KnowledgeFragment
|
||||
from chatballs.ai.retrieval import lexical_search
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.support_portals.content_services import (
|
||||
add_revision,
|
||||
archive_article,
|
||||
@@ -26,8 +26,8 @@ from chatballs.testing import TenantAPIClient, system_tenant_context
|
||||
|
||||
class AgentAttachmentTestCase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech",
|
||||
result = bootstrap_owner(
|
||||
email="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.organization = result.organization
|
||||
|
||||
@@ -5,7 +5,7 @@ from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
@@ -23,11 +23,11 @@ class AgentCardTestCase(TestCase):
|
||||
"""Единая сущность «Агент» = канал + AI-конфигурация (ADR-HUB-0041 §4)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.operators = self.organization.employee_groups.get(name="Операторы")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def create_agent(self, name: str = "Приёмная", group_id: int | None = None):
|
||||
return self.client.post(
|
||||
@@ -110,7 +110,7 @@ class AgentCardListDetailTests(AgentCardTestCase):
|
||||
|
||||
def test_employee_is_denied(self) -> None:
|
||||
user = HumanUser.objects.create_user(
|
||||
email="employee@edevs.tech", password="employee-password"
|
||||
email="employee@example.com", password="employee-password"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
user=user,
|
||||
@@ -119,7 +119,7 @@ class AgentCardListDetailTests(AgentCardTestCase):
|
||||
position_title="Оператор",
|
||||
)
|
||||
client = APIClient()
|
||||
client.login(username="employee@edevs.tech", password="employee-password")
|
||||
client.login(username="employee@example.com", password="employee-password")
|
||||
self.assertEqual(client.get("/api/v1/agents/").status_code, 403)
|
||||
|
||||
def test_other_organization_card_is_not_found(self) -> None:
|
||||
|
||||
@@ -8,7 +8,7 @@ from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.retrieval import lexical_search, semantic_search
|
||||
from chatballs.ai.runtime import knowledge_catalog
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.testing import TenantAPIClient, system_tenant_context
|
||||
@@ -16,8 +16,8 @@ from chatballs.testing import TenantAPIClient, system_tenant_context
|
||||
|
||||
class AgentKnowledgeAssignmentTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech",
|
||||
result = bootstrap_owner(
|
||||
email="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.organization = result.organization
|
||||
@@ -47,7 +47,7 @@ class AgentKnowledgeAssignmentTests(TestCase):
|
||||
)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.login(
|
||||
username="owner@edevs.tech",
|
||||
username="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
|
||||
@@ -114,8 +114,8 @@ class AgentKnowledgeAssignmentTests(TestCase):
|
||||
|
||||
class AgentKnowledgeRuntimeDefenseTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech",
|
||||
result = bootstrap_owner(
|
||||
email="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.context = system_tenant_context(result.organization)
|
||||
|
||||
@@ -6,7 +6,7 @@ from chatballs.ai.provider.base import ChatMessage
|
||||
from chatballs.ai.provider.demo import DemoProvider
|
||||
from chatballs.ai.provider.routing import _provider_from_integration
|
||||
from chatballs.ai.runtime import HANDOFF_TOKEN
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration, test_integration
|
||||
@@ -61,8 +61,8 @@ class DemoProviderTests(TestCase):
|
||||
|
||||
class DemoIntegrationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.context = system_tenant_context(self.organization)
|
||||
|
||||
def test_demo_integration_needs_no_key_and_checks_ok(self) -> None:
|
||||
|
||||
@@ -6,14 +6,14 @@ from chatballs.ai.knowledge_categories import create_category
|
||||
from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.testing import TenantAPIClient, system_tenant_context
|
||||
|
||||
|
||||
class KnowledgeBulkApiTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech",
|
||||
result = bootstrap_owner(
|
||||
email="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.organization = result.organization
|
||||
@@ -32,7 +32,7 @@ class KnowledgeBulkApiTests(TestCase):
|
||||
self.second = self._knowledge("Second")
|
||||
self.client = TenantAPIClient()
|
||||
self.client.login(
|
||||
username="owner@edevs.tech",
|
||||
username="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
|
||||
@@ -102,8 +102,8 @@ class KnowledgeBulkApiTests(TestCase):
|
||||
|
||||
class AgentCategoryKnowledgeSelectionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech",
|
||||
result = bootstrap_owner(
|
||||
email="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.organization = result.organization
|
||||
@@ -132,7 +132,7 @@ class AgentCategoryKnowledgeSelectionTests(TestCase):
|
||||
)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.login(
|
||||
username="owner@edevs.tech",
|
||||
username="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@ import json
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
|
||||
class KnowledgeDetailPayloadTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
organization = Organization.objects.get(slug="edevs")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
organization = Organization.objects.get(slug="demo")
|
||||
self.base = f"/api/v1/organizations/{organization.public_id}/ai/knowledge"
|
||||
|
||||
def test_detail_exposes_baseline_information_without_expanding_list_payload(self) -> None:
|
||||
|
||||
@@ -7,14 +7,14 @@ from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
|
||||
from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeCategory
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.testing import TenantAPIClient, system_tenant_context
|
||||
|
||||
|
||||
class KnowledgeMetadataImportTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech",
|
||||
result = bootstrap_owner(
|
||||
email="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.organization = result.organization
|
||||
@@ -23,14 +23,14 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
context=self.context,
|
||||
name="Products",
|
||||
)
|
||||
self.foxray = create_category(
|
||||
self.app = create_category(
|
||||
context=self.context,
|
||||
name="FoxRay",
|
||||
parent=self.products,
|
||||
)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.login(
|
||||
username="owner@edevs.tech",
|
||||
username="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
|
||||
@@ -63,7 +63,7 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
|
||||
self.assertEqual(response.json()["created"], 1)
|
||||
knowledge = Knowledge.objects.get(title="FoxRay support")
|
||||
self.assertEqual(knowledge.category_id, self.foxray.id)
|
||||
self.assertEqual(knowledge.category_id, self.app.id)
|
||||
|
||||
def test_unknown_category_path_fails_per_document(self) -> None:
|
||||
response = self._import(
|
||||
@@ -115,7 +115,7 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
|
||||
self.assertEqual(response.json()["updated"], 1)
|
||||
knowledge.refresh_from_db()
|
||||
self.assertEqual(knowledge.category_id, self.foxray.id)
|
||||
self.assertEqual(knowledge.category_id, self.app.id)
|
||||
self.assertTrue(agent.knowledge_items.filter(id=knowledge.id).exists())
|
||||
|
||||
def test_metadata_only_update_keeps_existing_fragments(self) -> None:
|
||||
@@ -135,7 +135,7 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
|
||||
self.assertEqual(response.json()["updated"], 1)
|
||||
knowledge.refresh_from_db()
|
||||
self.assertEqual(knowledge.category_id, self.foxray.id)
|
||||
self.assertEqual(knowledge.category_id, self.app.id)
|
||||
self.assertEqual(
|
||||
list(knowledge.fragments.values_list("id", flat=True)),
|
||||
fragment_ids,
|
||||
|
||||
@@ -16,7 +16,7 @@ from chatballs.ai.provider.routing import (
|
||||
)
|
||||
from chatballs.ai.provider_selection import configure_agent_provider
|
||||
from chatballs.ai.tests import make_channel_with_agent
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration
|
||||
@@ -28,14 +28,14 @@ class ProviderModeTests(TestCase):
|
||||
"""BYOK — единственный режим работы AI (ADR-HUB-0042 §3)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.context = system_tenant_context(self.organization)
|
||||
self.channel, self.agent = make_channel_with_agent(
|
||||
self.organization,
|
||||
code="provider-mode-sales",
|
||||
name="Provider mode — продажи",
|
||||
product=Product.objects.get(code="firepage"),
|
||||
product=Product.objects.get(code="site"),
|
||||
)
|
||||
|
||||
def _link_integration(
|
||||
@@ -132,8 +132,8 @@ class AgentProviderOwnershipTests(TestCase):
|
||||
"""SPEC-HUB-0027 §9 — провайдер живёт на агенте, канал не изменяется."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.context = system_tenant_context(self.organization)
|
||||
self.channel, self.agent = make_channel_with_agent(
|
||||
self.organization, code="byok", name="BYOK"
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.ai.models import Knowledge
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.models import OrganizationStorageUsage
|
||||
@@ -19,11 +19,11 @@ _MEDIA_ROOT = tempfile.mkdtemp(prefix="c04-storage-isolation-")
|
||||
@override_settings(MEDIA_ROOT=_MEDIA_ROOT)
|
||||
class TenantStorageIsolationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech",
|
||||
bootstrap_owner(
|
||||
email="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.knowledge = Knowledge.objects.create(
|
||||
organization=self.organization,
|
||||
category=ensure_uncategorized_category(self.organization),
|
||||
@@ -31,7 +31,7 @@ class TenantStorageIsolationTests(TestCase):
|
||||
)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.login(
|
||||
username="owner@edevs.tech",
|
||||
username="owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from chatballs.testing import TenantAPIClient as APIClient, system_tenant_contex
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFragment
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.products.models import Product
|
||||
|
||||
@@ -39,8 +39,8 @@ def make_knowledge(organization, *, title, content=""):
|
||||
|
||||
class AIAgentInvariantTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
|
||||
def test_channel_has_at_most_one_agent(self) -> None:
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -48,8 +48,8 @@ class AIAgentInvariantTests(TestCase):
|
||||
from chatballs.ai.services import AgentCreateInput, create_agent
|
||||
|
||||
channel, _ = make_channel_with_agent(
|
||||
self.organization, code="firepage-sales", name="FirePage — продажи",
|
||||
product=Product.objects.get(code="firepage"),
|
||||
self.organization, code="site-sales", name="Сайт — продажи",
|
||||
product=Product.objects.get(code="site"),
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
create_agent(
|
||||
@@ -71,16 +71,16 @@ class AIAgentInvariantTests(TestCase):
|
||||
|
||||
class AIAgentPermissionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
operator = HumanUser.objects.create_user(email="operator@example.com", password="operator-password")
|
||||
OrganizationMembership.objects.create(
|
||||
user=operator,
|
||||
organization=Organization.objects.get(slug="edevs"),
|
||||
organization=Organization.objects.get(slug="demo"),
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Оператор",
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.login(username="operator@edevs.tech", password="operator-password")
|
||||
self.client.login(username="operator@example.com", password="operator-password")
|
||||
|
||||
def test_operator_cannot_access_knowledge(self) -> None:
|
||||
response = self.client.get("/api/v1/ai/knowledge/")
|
||||
@@ -89,10 +89,10 @@ class AIAgentPermissionTests(TestCase):
|
||||
|
||||
class KnowledgeApiTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def _create(self, **overrides):
|
||||
payload = {"title": "FAQ", "description": "Ответы на вопросы", "content": "Refund policy details here."}
|
||||
@@ -153,10 +153,10 @@ class KnowledgeApiTests(TestCase):
|
||||
@override_settings(MEDIA_ROOT=_MEDIA_ROOT)
|
||||
class KnowledgeAttachmentTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
self.knowledge = make_knowledge(
|
||||
self.organization,
|
||||
title="FAQ",
|
||||
@@ -211,10 +211,10 @@ class KnowledgeAttachmentTests(TestCase):
|
||||
|
||||
class KnowledgeImportTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def _import(self, documents):
|
||||
return self.client.post(
|
||||
@@ -265,8 +265,8 @@ class PiiRedactionTests(TestCase):
|
||||
def test_redacts_email_phone_and_long_numbers(self) -> None:
|
||||
from chatballs.ai.pii import redact
|
||||
|
||||
cleaned = redact("Пишите a.kotova@edevs.tech, тел +7 916 245 14 02, карта 4111 1111 1111 1111")
|
||||
self.assertNotIn("a.kotova@edevs.tech", cleaned)
|
||||
cleaned = redact("Пишите a.kotova@example.com, тел +7 916 245 14 02, карта 4111 1111 1111 1111")
|
||||
self.assertNotIn("a.kotova@example.com", cleaned)
|
||||
self.assertNotIn("4111", cleaned)
|
||||
self.assertNotIn("916 245", cleaned)
|
||||
self.assertIn("[email]", cleaned)
|
||||
@@ -324,11 +324,11 @@ class ProviderFactoryTests(TestCase):
|
||||
|
||||
class ChatInvocationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.channel, self.agent = make_channel_with_agent(
|
||||
self.organization, code="firepage-sales", name="FirePage — продажи",
|
||||
product=Product.objects.get(code="firepage"),
|
||||
self.organization, code="site-sales", name="Сайт — продажи",
|
||||
product=Product.objects.get(code="site"),
|
||||
)
|
||||
|
||||
def test_chat_records_invocation_with_cost(self) -> None:
|
||||
@@ -443,11 +443,11 @@ class TextExtractionTests(TestCase):
|
||||
|
||||
class KnowledgeRetrievalTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.channel, self.agent = make_channel_with_agent(
|
||||
self.organization, code="firepage-sales", name="FirePage — продажи",
|
||||
product=Product.objects.get(code="firepage"),
|
||||
self.organization, code="site-sales", name="Сайт — продажи",
|
||||
product=Product.objects.get(code="site"),
|
||||
)
|
||||
from chatballs.ai.services import KnowledgeInput, create_knowledge
|
||||
|
||||
@@ -490,13 +490,13 @@ class KnowledgeRetrievalTests(TestCase):
|
||||
|
||||
class AgentRuntimeTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.channel, self.agent = make_channel_with_agent(
|
||||
self.organization, code="firepage-sales", name="FirePage — продажи",
|
||||
product=Product.objects.get(code="firepage"),
|
||||
self.organization, code="site-sales", name="Сайт — продажи",
|
||||
product=Product.objects.get(code="site"),
|
||||
)
|
||||
self.agent.persona = "Ты — ассистент FirePage."
|
||||
self.agent.persona = "Ты — ассистент Сайт."
|
||||
self.agent.tone = "Коротко."
|
||||
self.agent.instructions = "Отвечай только по знаниям."
|
||||
self.agent.save()
|
||||
@@ -545,7 +545,7 @@ class AgentRuntimeTests(TestCase):
|
||||
|
||||
def test_agent_card_test_chat_endpoint(self) -> None:
|
||||
client = APIClient()
|
||||
client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/agents/{self.channel.id}/test-chat/",
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, Conversation
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
@@ -44,12 +44,12 @@ class CallDomainMixin:
|
||||
могли использовать TransactionTestCase (consumer работает в потоках)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.operators_group = self.organization.employee_groups.get(name="Операторы")
|
||||
self.support_group = self.organization.employee_groups.get(name="Поддержка")
|
||||
self.owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
self.operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
self.owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self.operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
group=self.operators_group,
|
||||
@@ -80,7 +80,7 @@ class CallDomainMixin:
|
||||
|
||||
def create_support_operator(self) -> HumanUser:
|
||||
user = HumanUser.objects.create_user(
|
||||
email="support-operator@edevs.tech",
|
||||
email="support-operator@example.com",
|
||||
password="support-password",
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
|
||||
@@ -12,7 +12,7 @@ class InternalCallApiTests(CallTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def test_create_call_returns_staff_token_but_not_invite_token(self) -> None:
|
||||
response = self.client.post(
|
||||
|
||||
@@ -32,7 +32,7 @@ class CancelCallApiTests(CallTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def test_initiator_cancels_pending_call(self) -> None:
|
||||
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
|
||||
|
||||
@@ -9,8 +9,8 @@ from chatballs.calls.serializers import ice_servers_payload
|
||||
from chatballs.calls.turn import turn_credentials
|
||||
|
||||
SECRET = "coturn-shared-secret"
|
||||
TURN_URLS = ["turn:hub.edevs.tech:3478?transport=udp", "turn:hub.edevs.tech:3478?transport=tcp"]
|
||||
STUN_URLS = ["stun:hub.edevs.tech:3478"]
|
||||
TURN_URLS = ["turn:example.com:3478?transport=udp", "turn:example.com:3478?transport=tcp"]
|
||||
STUN_URLS = ["stun:example.com:3478"]
|
||||
|
||||
|
||||
def _expected_credential(username: str, secret: str = SECRET) -> str:
|
||||
|
||||
@@ -12,7 +12,7 @@ class Channel(models.Model):
|
||||
# Группа видимости (ADR-HUB-0043): новые диалоги канала попадают в неё.
|
||||
# NULL — диалоги видны всем сотрудникам.
|
||||
group = models.ForeignKey("identity.EmployeeGroup", on_delete=models.SET_NULL, related_name="channels", null=True, blank=True)
|
||||
# Продукт опционален: непродуктовый канал — главный сайт edevs.
|
||||
# Продукт опционален: непродуктовый канал — сайт компании.
|
||||
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
|
||||
# LLM-провайдер канала (ADR-HUB-0020).
|
||||
provider_integration = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
|
||||
|
||||
@@ -1,94 +1,188 @@
|
||||
"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P5.␍
|
||||
␍
|
||||
Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит␍
|
||||
только Python-дефолты. Весь риск миграции сосредоточен в функции приведения␍
|
||||
данных, поэтому она проверяется напрямую на реальном реестре моделей — это␍
|
||||
честнее, чем поднимать историческое состояние, где `identity` откатывается␍
|
||||
рассинхронно со схемой БД.␍
|
||||
"""␍
|
||||
␍
|
||||
import importlib␍
|
||||
␍
|
||||
from django.apps import apps␍
|
||||
from django.test import TestCase␍
|
||||
␍
|
||||
from chatballs.channels.models import Channel␍
|
||||
from chatballs.identity.models import Organization␍
|
||||
from chatballs.products.models import Product␍
|
||||
␍
|
||||
migration = importlib.import_module(␍
|
||||
"chatballs.channels.migrations.0005_enforce_policy_invariants"␍
|
||||
)␍
|
||||
␍
|
||||
␍
|
||||
class PolicyInvariantMigrationTests(TestCase):␍
|
||||
def setUp(self) -> None:␍
|
||||
self.organization = Organization.objects.create(name="Edevs", slug="edevs")␍
|
||||
self.product = Product.objects.create(␍
|
||||
organization=self.organization, code="foxray", name="FoxRay"␍
|
||||
)␍
|
||||
␍
|
||||
def _channel(self, code: str, **fields) -> Channel:␍
|
||||
return Channel.objects.create(␍
|
||||
organization=self.organization, code=code, name=code, **fields␍
|
||||
)␍
|
||||
␍
|
||||
def _run(self) -> None:␍
|
||||
migration.relax_non_product_channels(apps, None)␍
|
||||
␍
|
||||
def test_clears_commercial_flags_on_non_product_channels(self) -> None:␍
|
||||
# Ровно случай канала edevs: непродуктовый, но с checkout и attribution.␍
|
||||
violating = self._channel(␍
|
||||
"edevs", allow_checkout_actions=True, allow_sales_attribution=True␍
|
||||
)␍
|
||||
␍
|
||||
self._run()␍
|
||||
␍
|
||||
violating.refresh_from_db()␍
|
||||
self.assertFalse(violating.allow_checkout_actions)␍
|
||||
self.assertFalse(violating.allow_sales_attribution)␍
|
||||
# Остальные флаги непродуктового канала не трогаются.␍
|
||||
self.assertTrue(violating.allow_anonymous_sessions)␍
|
||||
self.assertTrue(violating.allow_self_reported_contact)␍
|
||||
␍
|
||||
def test_leaves_product_channels_untouched(self) -> None:␍
|
||||
product_channel = self._channel(␍
|
||||
"foxray-sales",␍
|
||||
product=self.product,␍
|
||||
allow_checkout_actions=True,␍
|
||||
allow_sales_attribution=True,␍
|
||||
)␍
|
||||
␍
|
||||
self._run()␍
|
||||
␍
|
||||
product_channel.refresh_from_db()␍
|
||||
self.assertTrue(product_channel.allow_checkout_actions)␍
|
||||
self.assertTrue(product_channel.allow_sales_attribution)␍
|
||||
␍
|
||||
def test_stops_the_rollout_on_p3_p5_violations(self) -> None:␍
|
||||
# Обязательная идентичность без продукта — смена смысла канала,␍
|
||||
# выбирать за владельца нельзя, поэтому выкат останавливается.␍
|
||||
self._channel("broken", requires_authenticated_product_identity=True)␍
|
||||
untouched = self._channel(␍
|
||||
"edevs", allow_checkout_actions=True, allow_sales_attribution=True␍
|
||||
)␍
|
||||
␍
|
||||
with self.assertRaises(RuntimeError) as error:␍
|
||||
self._run()␍
|
||||
␍
|
||||
self.assertIn("broken", str(error.exception))␍
|
||||
untouched.refresh_from_db()␍
|
||||
self.assertTrue(untouched.allow_checkout_actions)␍
|
||||
␍
|
||||
def test_is_idempotent(self) -> None:␍
|
||||
self._channel("edevs", allow_checkout_actions=True)␍
|
||||
␍
|
||||
self._run()␍
|
||||
self._run()␍
|
||||
␍
|
||||
self.assertEqual(␍
|
||||
Channel.objects.filter(␍
|
||||
product__isnull=True, allow_checkout_actions=True␍
|
||||
).count(),␍
|
||||
0,␍
|
||||
)␍
|
||||
"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P5.
|
||||
|
||||
|
||||
|
||||
Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит
|
||||
|
||||
только Python-дефолты. Весь риск миграции сосредоточен в функции приведения
|
||||
|
||||
данных, поэтому она проверяется напрямую на реальном реестре моделей — это
|
||||
|
||||
честнее, чем поднимать историческое состояние, где `identity` откатывается
|
||||
|
||||
рассинхронно со схемой БД.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
import importlib
|
||||
|
||||
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
from chatballs.products.models import Product
|
||||
|
||||
|
||||
|
||||
migration = importlib.import_module(
|
||||
|
||||
"chatballs.channels.migrations.0005_enforce_policy_invariants"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class PolicyInvariantMigrationTests(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
|
||||
self.organization = Organization.objects.create(name="Acme", slug="acme")
|
||||
|
||||
self.product = Product.objects.create(
|
||||
|
||||
organization=self.organization, code="app", name="FoxRay"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _channel(self, code: str, **fields) -> Channel:
|
||||
|
||||
return Channel.objects.create(
|
||||
|
||||
organization=self.organization, code=code, name=code, **fields
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _run(self) -> None:
|
||||
|
||||
migration.relax_non_product_channels(apps, None)
|
||||
|
||||
|
||||
|
||||
def test_clears_commercial_flags_on_non_product_channels(self) -> None:
|
||||
|
||||
# Ровно случай канала acme: непродуктовый, но с checkout и attribution.
|
||||
|
||||
violating = self._channel(
|
||||
|
||||
"acme", allow_checkout_actions=True, allow_sales_attribution=True
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
self._run()
|
||||
|
||||
|
||||
|
||||
violating.refresh_from_db()
|
||||
|
||||
self.assertFalse(violating.allow_checkout_actions)
|
||||
|
||||
self.assertFalse(violating.allow_sales_attribution)
|
||||
|
||||
# Остальные флаги непродуктового канала не трогаются.
|
||||
|
||||
self.assertTrue(violating.allow_anonymous_sessions)
|
||||
|
||||
self.assertTrue(violating.allow_self_reported_contact)
|
||||
|
||||
|
||||
|
||||
def test_leaves_product_channels_untouched(self) -> None:
|
||||
|
||||
product_channel = self._channel(
|
||||
|
||||
"app-sales",
|
||||
|
||||
product=self.product,
|
||||
|
||||
allow_checkout_actions=True,
|
||||
|
||||
allow_sales_attribution=True,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
self._run()
|
||||
|
||||
|
||||
|
||||
product_channel.refresh_from_db()
|
||||
|
||||
self.assertTrue(product_channel.allow_checkout_actions)
|
||||
|
||||
self.assertTrue(product_channel.allow_sales_attribution)
|
||||
|
||||
|
||||
|
||||
def test_stops_the_rollout_on_p3_p5_violations(self) -> None:
|
||||
|
||||
# Обязательная идентичность без продукта — смена смысла канала,
|
||||
|
||||
# выбирать за владельца нельзя, поэтому выкат останавливается.
|
||||
|
||||
self._channel("broken", requires_authenticated_product_identity=True)
|
||||
|
||||
untouched = self._channel(
|
||||
|
||||
"acme", allow_checkout_actions=True, allow_sales_attribution=True
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
with self.assertRaises(RuntimeError) as error:
|
||||
|
||||
self._run()
|
||||
|
||||
|
||||
|
||||
self.assertIn("broken", str(error.exception))
|
||||
|
||||
untouched.refresh_from_db()
|
||||
|
||||
self.assertTrue(untouched.allow_checkout_actions)
|
||||
|
||||
|
||||
|
||||
def test_is_idempotent(self) -> None:
|
||||
|
||||
self._channel("acme", allow_checkout_actions=True)
|
||||
|
||||
|
||||
|
||||
self._run()
|
||||
|
||||
self._run()
|
||||
|
||||
|
||||
|
||||
self.assertEqual(
|
||||
|
||||
Channel.objects.filter(
|
||||
|
||||
product__isnull=True, allow_checkout_actions=True
|
||||
|
||||
).count(),
|
||||
|
||||
0,
|
||||
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ PROVIDER_CODE = {
|
||||
"WEB": "WEB",
|
||||
"EMAIL": "EMAIL",
|
||||
}
|
||||
PRODUCT_CODE = {"firepage": "FP", "foxray": "FX"}
|
||||
|
||||
# Понятные подписи для аудита диалогов.
|
||||
AUDIT_LABELS = {
|
||||
@@ -64,14 +63,14 @@ def clients_overview(organization_id: int) -> list[dict]:
|
||||
if not conversations:
|
||||
continue # клиенты — те, кто писал
|
||||
channels: set[str] = set()
|
||||
products: set[str] = set()
|
||||
products: dict[str, str] = {}
|
||||
open_dialogs = 0
|
||||
for conversation in conversations:
|
||||
provider = conversation.connection.provider if conversation.connection_id else None
|
||||
if provider in PROVIDER_CODE:
|
||||
channels.add(PROVIDER_CODE[provider])
|
||||
if conversation.channel.product_id and conversation.channel.product.code in PRODUCT_CODE:
|
||||
products.add(PRODUCT_CODE[conversation.channel.product.code])
|
||||
if conversation.channel.product_id:
|
||||
products[conversation.channel.product.code] = conversation.channel.product.name
|
||||
if conversation.lifecycle == LifecycleState.OPEN:
|
||||
open_dialogs += 1
|
||||
latest = conversations[0]
|
||||
@@ -93,7 +92,7 @@ def clients_overview(organization_id: int) -> list[dict]:
|
||||
# Первый непустой @логин среди identity каналов (остальные — в карточке).
|
||||
"username": next((identity.username for identity in contact.identities.all() if identity.username), ""),
|
||||
"channels": sorted(channels),
|
||||
"products": sorted(products),
|
||||
"products": [{"code": code, "name": name} for code, name in sorted(products.items())],
|
||||
"openDialogs": open_dialogs,
|
||||
"totalDialogs": len(conversations),
|
||||
"lastActivityAt": latest.last_activity_at.isoformat(),
|
||||
@@ -118,15 +117,15 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
raise Contact.DoesNotExist
|
||||
|
||||
channels: set[str] = set()
|
||||
products: set[str] = set()
|
||||
products: dict[str, str] = {}
|
||||
open_dialogs = 0
|
||||
dialogs: list[dict] = []
|
||||
for conversation in conversations:
|
||||
provider = conversation.connection.provider if conversation.connection_id else None
|
||||
if provider in PROVIDER_CODE:
|
||||
channels.add(PROVIDER_CODE[provider])
|
||||
if conversation.channel.product_id and conversation.channel.product.code in PRODUCT_CODE:
|
||||
products.add(PRODUCT_CODE[conversation.channel.product.code])
|
||||
if conversation.channel.product_id:
|
||||
products[conversation.channel.product.code] = conversation.channel.product.name
|
||||
if conversation.lifecycle == LifecycleState.OPEN:
|
||||
open_dialogs += 1
|
||||
last = conversation.messages.order_by("-created_at").first()
|
||||
@@ -202,7 +201,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
"",
|
||||
),
|
||||
"channels": sorted(channels),
|
||||
"products": sorted(products),
|
||||
"products": [{"code": code, "name": name} for code, name in sorted(products.items())],
|
||||
"openDialogs": open_dialogs,
|
||||
"totalDialogs": len(conversations),
|
||||
"firstContactAt": contact.created_at.isoformat(),
|
||||
|
||||
@@ -1,283 +1,566 @@
|
||||
"""Email-транспорт: разбор писем, UID-курсор, тредирование (SPEC-HUB-0025 §5)."""␍
|
||||
␍
|
||||
from email import message_from_bytes, policy␍
|
||||
from email.message import EmailMessage as MimeMessage␍
|
||||
from unittest import mock␍
|
||||
␍
|
||||
from django.test import TestCase␍
|
||||
␍
|
||||
from chatballs.channels.models import Channel␍
|
||||
from chatballs.conversations.models import Contact, Conversation, MessageAuthor␍
|
||||
from chatballs.conversations.clients import client_detail, clients_overview␍
|
||||
from chatballs.conversations.serializers import conversation_payload␍
|
||||
from chatballs.conversations.transports import email as email_transport␍
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner␍
|
||||
from chatballs.identity.models import Organization␍
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider␍
|
||||
␍
|
||||
EMAIL_CONFIG = {␍
|
||||
"email": "support@edevs.tech",␍
|
||||
"imap_host": "imap.test",␍
|
||||
"imap_port": 993,␍
|
||||
"imap_ssl": True,␍
|
||||
"smtp_host": "smtp.test",␍
|
||||
"smtp_port": 465,␍
|
||||
"smtp_ssl": True,␍
|
||||
}␍
|
||||
␍
|
||||
␍
|
||||
def _raw(*, from_="Иван Петров <Ivan@example.com>", subject="Вопрос по FoxRay", text="Здравствуйте!", message_id="<m1@example.com>", html=None, attach=False) -> bytes:␍
|
||||
message = MimeMessage()␍
|
||||
message["From"] = from_␍
|
||||
message["Subject"] = subject␍
|
||||
if message_id:␍
|
||||
message["Message-ID"] = message_id␍
|
||||
if html is not None:␍
|
||||
message.add_alternative(html, subtype="html")␍
|
||||
else:␍
|
||||
message.set_content(text)␍
|
||||
if attach:␍
|
||||
message.add_attachment(b"%PDF", maintype="application", subtype="pdf", filename="doc.pdf")␍
|
||||
return message.as_bytes()␍
|
||||
␍
|
||||
␍
|
||||
def _parsed(raw: bytes):␍
|
||||
return message_from_bytes(raw, policy=policy.default)␍
|
||||
␍
|
||||
␍
|
||||
def _integration(**overrides) -> Integration:␍
|
||||
fields = {␍
|
||||
"kind": IntegrationKind.MESSENGER,␍
|
||||
"provider": IntegrationProvider.EMAIL,␍
|
||||
"name": "Почта",␍
|
||||
"secret": "app-password",␍
|
||||
"config": dict(EMAIL_CONFIG),␍
|
||||
"poll_marker": "",␍
|
||||
}␍
|
||||
fields.update(overrides)␍
|
||||
return Integration(**fields)␍
|
||||
␍
|
||||
␍
|
||||
class EmailNormalizeTests(TestCase):␍
|
||||
def test_plain_message_becomes_inbound_with_thread_meta(self) -> None:␍
|
||||
inbound, message_id = email_transport._normalize(␍
|
||||
_parsed(_raw()), own_address="support@edevs.tech", fallback_id="7:100"␍
|
||||
)␍
|
||||
self.assertIsNotNone(inbound)␍
|
||||
self.assertEqual(inbound.external_id, "<m1@example.com>")␍
|
||||
self.assertEqual(inbound.user_id, "ivan@example.com")␍
|
||||
self.assertEqual(inbound.chat_id, "ivan@example.com")␍
|
||||
self.assertEqual(inbound.display_name, "Иван Петров")␍
|
||||
self.assertEqual(inbound.text, "Здравствуйте!")␍
|
||||
self.assertEqual(inbound.thread_meta, {"subject": "Вопрос по FoxRay", "last_message_id": "<m1@example.com>"})␍
|
||||
self.assertEqual(message_id, "<m1@example.com>")␍
|
||||
␍
|
||||
def test_own_message_is_skipped(self) -> None:␍
|
||||
inbound, _ = email_transport._normalize(␍
|
||||
_parsed(_raw(from_="support@edevs.tech")), own_address="support@edevs.tech", fallback_id="7:100"␍
|
||||
)␍
|
||||
self.assertIsNone(inbound)␍
|
||||
␍
|
||||
def test_html_only_body_is_flattened_to_text(self) -> None:␍
|
||||
inbound, _ = email_transport._normalize(␍
|
||||
_parsed(_raw(html="<p>Добрый день!</p><p>Сколько стоит «FoxRay»?</p>")),␍
|
||||
own_address="support@edevs.tech",␍
|
||||
fallback_id="7:100",␍
|
||||
)␍
|
||||
self.assertIsNotNone(inbound)␍
|
||||
self.assertIn("Добрый день!", inbound.text)␍
|
||||
self.assertIn("«FoxRay»", inbound.text)␍
|
||||
self.assertNotIn("<p>", inbound.text)␍
|
||||
self.assertIn("<p>Добрый день!</p>", inbound.content_html)␍
|
||||
␍
|
||||
def test_html_body_is_sanitized_before_ingest(self) -> None:␍
|
||||
inbound, _ = email_transport._normalize(␍
|
||||
_parsed(␍
|
||||
_raw(␍
|
||||
html=(␍
|
||||
'<p onclick="steal()">Здравствуйте!</p>'␍
|
||||
'<script>alert("xss")</script>'␍
|
||||
'<a href="javascript:alert(1)">опасная ссылка</a>'␍
|
||||
'<a href="https://example.com/path">сайт</a>'␍
|
||||
)␍
|
||||
)␍
|
||||
),␍
|
||||
own_address="support@edevs.tech",␍
|
||||
fallback_id="7:100",␍
|
||||
)␍
|
||||
self.assertIsNotNone(inbound)␍
|
||||
self.assertNotIn("onclick", inbound.content_html)␍
|
||||
self.assertNotIn("script", inbound.content_html)␍
|
||||
self.assertNotIn("alert", inbound.content_html)␍
|
||||
self.assertNotIn("javascript:", inbound.content_html)␍
|
||||
self.assertIn('href="https://example.com/path"', inbound.content_html)␍
|
||||
␍
|
||||
def test_attachments_add_note(self) -> None:␍
|
||||
inbound, _ = email_transport._normalize(␍
|
||||
_parsed(_raw(attach=True)), own_address="support@edevs.tech", fallback_id="7:100"␍
|
||||
)␍
|
||||
self.assertIn("[Вложения не поддерживаются: 1 файл(ов)]", inbound.text)␍
|
||||
␍
|
||||
def test_missing_message_id_falls_back_to_uid(self) -> None:␍
|
||||
inbound, _ = email_transport._normalize(␍
|
||||
_parsed(_raw(message_id="")), own_address="support@edevs.tech", fallback_id="7:100"␍
|
||||
)␍
|
||||
self.assertEqual(inbound.external_id, "7:100")␍
|
||||
␍
|
||||
␍
|
||||
class _FakeImap:␍
|
||||
def __init__(self, *, validity: int, next_uid: int, mailbox: dict[int, bytes]):␍
|
||||
self.validity = validity␍
|
||||
self.next_uid = next_uid␍
|
||||
self.mailbox = mailbox␍
|
||||
␍
|
||||
def select(self, name, readonly=False):␍
|
||||
return "OK", [b""]␍
|
||||
␍
|
||||
def status(self, name, item):␍
|
||||
value = self.validity if "UIDVALIDITY" in item else self.next_uid␍
|
||||
key = "UIDVALIDITY" if "UIDVALIDITY" in item else "UIDNEXT"␍
|
||||
return "OK", [f"INBOX ({key} {value})".encode()]␍
|
||||
␍
|
||||
def uid(self, command, *args):␍
|
||||
if command == "SEARCH":␍
|
||||
# Реальный IMAP на «N:*» всегда возвращает и старшее письмо.␍
|
||||
return "OK", [" ".join(str(u) for u in sorted(self.mailbox)).encode() or b""]␍
|
||||
return "OK", [(b"1 (RFC822 {0}", self.mailbox[int(args[0])]), b")"]␍
|
||||
␍
|
||||
def logout(self):␍
|
||||
return "BYE", []␍
|
||||
␍
|
||||
␍
|
||||
class EmailPollTests(TestCase):␍
|
||||
def _poll(self, integration, fake):␍
|
||||
with mock.patch.object(email_transport, "_imap_connect", return_value=fake):␍
|
||||
return email_transport.poll_updates(integration)␍
|
||||
␍
|
||||
def test_first_run_sets_cursor_without_ingesting_history(self) -> None:␍
|
||||
fake = _FakeImap(validity=7, next_uid=100, mailbox={98: _raw(), 99: _raw()})␍
|
||||
messages, marker = self._poll(_integration(poll_marker=""), fake)␍
|
||||
self.assertEqual(messages, [])␍
|
||||
self.assertEqual(marker, "7:99")␍
|
||||
␍
|
||||
def test_new_mail_after_cursor_is_ingested(self) -> None:␍
|
||||
fake = _FakeImap(validity=7, next_uid=101, mailbox={99: _raw(message_id="<old@example.com>"), 100: _raw()})␍
|
||||
messages, marker = self._poll(_integration(poll_marker="7:99"), fake)␍
|
||||
self.assertEqual([m.external_id for m in messages], ["<m1@example.com>"])␍
|
||||
self.assertEqual(marker, "7:100")␍
|
||||
␍
|
||||
def test_uidvalidity_change_resets_cursor(self) -> None:␍
|
||||
fake = _FakeImap(validity=8, next_uid=50, mailbox={49: _raw()})␍
|
||||
messages, marker = self._poll(_integration(poll_marker="7:99"), fake)␍
|
||||
self.assertEqual(messages, [])␍
|
||||
self.assertEqual(marker, "8:49")␍
|
||||
␍
|
||||
def test_connection_error_keeps_cursor(self) -> None:␍
|
||||
integration = _integration(poll_marker="7:99")␍
|
||||
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):␍
|
||||
messages, marker = email_transport.poll_updates(integration)␍
|
||||
self.assertEqual(messages, [])␍
|
||||
self.assertEqual(marker, "7:99")␍
|
||||
␍
|
||||
␍
|
||||
class EmailSendTests(TestCase):␍
|
||||
def setUp(self) -> None:␍
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")␍
|
||||
self.organization = Organization.objects.get(slug="edevs")␍
|
||||
self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")␍
|
||||
self.integration = _integration(organization=self.organization, channel=self.channel)␍
|
||||
self.integration.save()␍
|
||||
contact = Contact.objects.create(organization=self.organization, name="Иван")␍
|
||||
self.conversation = Conversation.objects.create(␍
|
||||
organization=self.organization,␍
|
||||
channel=self.channel,␍
|
||||
connection=self.integration,␍
|
||||
contact=contact,␍
|
||||
external_chat_id="ivan@example.com",␍
|
||||
transport_meta={"subject": "Вопрос по FoxRay", "last_message_id": "<m1@example.com>"},␍
|
||||
)␍
|
||||
␍
|
||||
def test_reply_goes_into_the_same_thread(self) -> None:␍
|
||||
with mock.patch.object(email_transport.smtplib, "SMTP_SSL") as smtp_cls:␍
|
||||
ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Добрый день!")␍
|
||||
self.assertTrue(ok)␍
|
||||
smtp = smtp_cls.return_value␍
|
||||
smtp.login.assert_called_once_with("support@edevs.tech", "app-password")␍
|
||||
outgoing = smtp.send_message.call_args.args[0]␍
|
||||
self.assertEqual(outgoing["To"], "ivan@example.com")␍
|
||||
self.assertEqual(outgoing["Subject"], "Re: Вопрос по FoxRay")␍
|
||||
self.assertEqual(outgoing["In-Reply-To"], "<m1@example.com>")␍
|
||||
self.assertEqual(outgoing["References"], "<m1@example.com>")␍
|
||||
␍
|
||||
def test_send_without_thread_meta_uses_fallback_subject(self) -> None:␍
|
||||
self.conversation.transport_meta = {}␍
|
||||
self.conversation.save(update_fields=["transport_meta"])␍
|
||||
with mock.patch.object(email_transport.smtplib, "SMTP_SSL") as smtp_cls:␍
|
||||
ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Добрый день!")␍
|
||||
self.assertTrue(ok)␍
|
||||
outgoing = smtp_cls.return_value.send_message.call_args.args[0]␍
|
||||
self.assertEqual(outgoing["Subject"], "Re: Ваше обращение")␍
|
||||
self.assertIsNone(outgoing["In-Reply-To"])␍
|
||||
␍
|
||||
def test_smtp_failure_returns_false(self) -> None:␍
|
||||
with mock.patch.object(email_transport.smtplib, "SMTP_SSL", side_effect=OSError("refused")):␍
|
||||
ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Привет")␍
|
||||
self.assertFalse(ok)␍
|
||||
␍
|
||||
␍
|
||||
class EmailIngestThreadMetaTests(TestCase):␍
|
||||
"""ingest пишет transport_meta: тема — от первого письма, Message-ID — последний."""␍
|
||||
␍
|
||||
def setUp(self) -> None:␍
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")␍
|
||||
self.organization = Organization.objects.get(slug="edevs")␍
|
||||
self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")␍
|
||||
self.integration = _integration(organization=self.organization, channel=self.channel)␍
|
||||
self.integration.save()␍
|
||||
␍
|
||||
def _ingest(self, *, external_id: str, subject: str, message_id: str) -> None:␍
|
||||
from chatballs.conversations.ingest import ingest_inbound␍
|
||||
from chatballs.conversations.transports.base import InboundMessage␍
|
||||
␍
|
||||
inbound = InboundMessage(␍
|
||||
external_id=external_id,␍
|
||||
user_id="ivan@example.com",␍
|
||||
chat_id="ivan@example.com",␍
|
||||
text="Здравствуйте!",␍
|
||||
display_name="Иван",␍
|
||||
thread_meta={"subject": subject, "last_message_id": message_id},␍
|
||||
)␍
|
||||
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)␍
|
||||
␍
|
||||
def test_subject_pinned_to_first_message_id_follows_last(self) -> None:␍
|
||||
self._ingest(external_id="<m1@example.com>", subject="Вопрос по FoxRay", message_id="<m1@example.com>")␍
|
||||
self._ingest(external_id="<m2@example.com>", subject="Re: Вопрос по FoxRay", message_id="<m2@example.com>")␍
|
||||
conversation = Conversation.objects.get(channel=self.channel)␍
|
||||
self.assertEqual(conversation.transport_meta["subject"], "Вопрос по FoxRay")␍
|
||||
self.assertEqual(conversation.transport_meta["last_message_id"], "<m2@example.com>")␍
|
||||
authors = list(conversation.messages.values_list("author_type", flat=True))␍
|
||||
self.assertIn(MessageAuthor.CONTACT, authors)␍
|
||||
␍
|
||||
def test_email_identity_is_exposed_in_dialog_and_contact_payloads(self) -> None:␍
|
||||
self._ingest(␍
|
||||
external_id="<identity@example.com>",␍
|
||||
subject="Контакты",␍
|
||||
message_id="<identity@example.com>",␍
|
||||
)␍
|
||||
conversation = Conversation.objects.get(channel=self.channel)␍
|
||||
dialog = conversation_payload(conversation, with_messages=True)␍
|
||||
self.assertEqual(dialog["connection"]["provider"], "EMAIL")␍
|
||||
self.assertEqual(dialog["contact"]["email"], "ivan@example.com")␍
|
||||
self.assertEqual(dialog["messages"][0]["contentHtml"], "")␍
|
||||
␍
|
||||
overview = clients_overview(self.organization.id)␍
|
||||
self.assertEqual(overview[0]["email"], "ivan@example.com")␍
|
||||
self.assertIn("EMAIL", overview[0]["channels"])␍
|
||||
␍
|
||||
detail = client_detail(self.organization.id, conversation.contact_id)␍
|
||||
self.assertEqual(detail["email"], "ivan@example.com")␍
|
||||
self.assertEqual(detail["identities"][0]["value"], "ivan@example.com")␍
|
||||
"""Email-транспорт: разбор писем, UID-курсор, тредирование (SPEC-HUB-0025 §5)."""
|
||||
|
||||
|
||||
|
||||
from email import message_from_bytes, policy
|
||||
|
||||
from email.message import EmailMessage as MimeMessage
|
||||
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
|
||||
from chatballs.conversations.models import Contact, Conversation, MessageAuthor
|
||||
|
||||
from chatballs.conversations.clients import client_detail, clients_overview
|
||||
|
||||
from chatballs.conversations.serializers import conversation_payload
|
||||
|
||||
from chatballs.conversations.transports import email as email_transport
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
|
||||
|
||||
|
||||
EMAIL_CONFIG = {
|
||||
|
||||
"email": "support@example.com",
|
||||
|
||||
"imap_host": "imap.test",
|
||||
|
||||
"imap_port": 993,
|
||||
|
||||
"imap_ssl": True,
|
||||
|
||||
"smtp_host": "smtp.test",
|
||||
|
||||
"smtp_port": 465,
|
||||
|
||||
"smtp_ssl": True,
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _raw(*, from_="Иван Петров <Ivan@example.com>", subject="Вопрос по FoxRay", text="Здравствуйте!", message_id="<m1@example.com>", html=None, attach=False) -> bytes:
|
||||
|
||||
message = MimeMessage()
|
||||
|
||||
message["From"] = from_
|
||||
|
||||
message["Subject"] = subject
|
||||
|
||||
if message_id:
|
||||
|
||||
message["Message-ID"] = message_id
|
||||
|
||||
if html is not None:
|
||||
|
||||
message.add_alternative(html, subtype="html")
|
||||
|
||||
else:
|
||||
|
||||
message.set_content(text)
|
||||
|
||||
if attach:
|
||||
|
||||
message.add_attachment(b"%PDF", maintype="application", subtype="pdf", filename="doc.pdf")
|
||||
|
||||
return message.as_bytes()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _parsed(raw: bytes):
|
||||
|
||||
return message_from_bytes(raw, policy=policy.default)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _integration(**overrides) -> Integration:
|
||||
|
||||
fields = {
|
||||
|
||||
"kind": IntegrationKind.MESSENGER,
|
||||
|
||||
"provider": IntegrationProvider.EMAIL,
|
||||
|
||||
"name": "Почта",
|
||||
|
||||
"secret": "app-password",
|
||||
|
||||
"config": dict(EMAIL_CONFIG),
|
||||
|
||||
"poll_marker": "",
|
||||
|
||||
}
|
||||
|
||||
fields.update(overrides)
|
||||
|
||||
return Integration(**fields)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class EmailNormalizeTests(TestCase):
|
||||
|
||||
def test_plain_message_becomes_inbound_with_thread_meta(self) -> None:
|
||||
|
||||
inbound, message_id = email_transport._normalize(
|
||||
|
||||
_parsed(_raw()), own_address="support@example.com", fallback_id="7:100"
|
||||
|
||||
)
|
||||
|
||||
self.assertIsNotNone(inbound)
|
||||
|
||||
self.assertEqual(inbound.external_id, "<m1@example.com>")
|
||||
|
||||
self.assertEqual(inbound.user_id, "ivan@example.com")
|
||||
|
||||
self.assertEqual(inbound.chat_id, "ivan@example.com")
|
||||
|
||||
self.assertEqual(inbound.display_name, "Иван Петров")
|
||||
|
||||
self.assertEqual(inbound.text, "Здравствуйте!")
|
||||
|
||||
self.assertEqual(inbound.thread_meta, {"subject": "Вопрос по FoxRay", "last_message_id": "<m1@example.com>"})
|
||||
|
||||
self.assertEqual(message_id, "<m1@example.com>")
|
||||
|
||||
|
||||
|
||||
def test_own_message_is_skipped(self) -> None:
|
||||
|
||||
inbound, _ = email_transport._normalize(
|
||||
|
||||
_parsed(_raw(from_="support@example.com")), own_address="support@example.com", fallback_id="7:100"
|
||||
|
||||
)
|
||||
|
||||
self.assertIsNone(inbound)
|
||||
|
||||
|
||||
|
||||
def test_html_only_body_is_flattened_to_text(self) -> None:
|
||||
|
||||
inbound, _ = email_transport._normalize(
|
||||
|
||||
_parsed(_raw(html="<p>Добрый день!</p><p>Сколько стоит «FoxRay»?</p>")),
|
||||
|
||||
own_address="support@example.com",
|
||||
|
||||
fallback_id="7:100",
|
||||
|
||||
)
|
||||
|
||||
self.assertIsNotNone(inbound)
|
||||
|
||||
self.assertIn("Добрый день!", inbound.text)
|
||||
|
||||
self.assertIn("«FoxRay»", inbound.text)
|
||||
|
||||
self.assertNotIn("<p>", inbound.text)
|
||||
|
||||
self.assertIn("<p>Добрый день!</p>", inbound.content_html)
|
||||
|
||||
|
||||
|
||||
def test_html_body_is_sanitized_before_ingest(self) -> None:
|
||||
|
||||
inbound, _ = email_transport._normalize(
|
||||
|
||||
_parsed(
|
||||
|
||||
_raw(
|
||||
|
||||
html=(
|
||||
|
||||
'<p onclick="steal()">Здравствуйте!</p>'
|
||||
|
||||
'<script>alert("xss")</script>'
|
||||
|
||||
'<a href="javascript:alert(1)">опасная ссылка</a>'
|
||||
|
||||
'<a href="https://example.com/path">сайт</a>'
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
),
|
||||
|
||||
own_address="support@example.com",
|
||||
|
||||
fallback_id="7:100",
|
||||
|
||||
)
|
||||
|
||||
self.assertIsNotNone(inbound)
|
||||
|
||||
self.assertNotIn("onclick", inbound.content_html)
|
||||
|
||||
self.assertNotIn("script", inbound.content_html)
|
||||
|
||||
self.assertNotIn("alert", inbound.content_html)
|
||||
|
||||
self.assertNotIn("javascript:", inbound.content_html)
|
||||
|
||||
self.assertIn('href="https://example.com/path"', inbound.content_html)
|
||||
|
||||
|
||||
|
||||
def test_attachments_add_note(self) -> None:
|
||||
|
||||
inbound, _ = email_transport._normalize(
|
||||
|
||||
_parsed(_raw(attach=True)), own_address="support@example.com", fallback_id="7:100"
|
||||
|
||||
)
|
||||
|
||||
self.assertIn("[Вложения не поддерживаются: 1 файл(ов)]", inbound.text)
|
||||
|
||||
|
||||
|
||||
def test_missing_message_id_falls_back_to_uid(self) -> None:
|
||||
|
||||
inbound, _ = email_transport._normalize(
|
||||
|
||||
_parsed(_raw(message_id="")), own_address="support@example.com", fallback_id="7:100"
|
||||
|
||||
)
|
||||
|
||||
self.assertEqual(inbound.external_id, "7:100")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class _FakeImap:
|
||||
|
||||
def __init__(self, *, validity: int, next_uid: int, mailbox: dict[int, bytes]):
|
||||
|
||||
self.validity = validity
|
||||
|
||||
self.next_uid = next_uid
|
||||
|
||||
self.mailbox = mailbox
|
||||
|
||||
|
||||
|
||||
def select(self, name, readonly=False):
|
||||
|
||||
return "OK", [b""]
|
||||
|
||||
|
||||
|
||||
def status(self, name, item):
|
||||
|
||||
value = self.validity if "UIDVALIDITY" in item else self.next_uid
|
||||
|
||||
key = "UIDVALIDITY" if "UIDVALIDITY" in item else "UIDNEXT"
|
||||
|
||||
return "OK", [f"INBOX ({key} {value})".encode()]
|
||||
|
||||
|
||||
|
||||
def uid(self, command, *args):
|
||||
|
||||
if command == "SEARCH":
|
||||
|
||||
# Реальный IMAP на «N:*» всегда возвращает и старшее письмо.
|
||||
|
||||
return "OK", [" ".join(str(u) for u in sorted(self.mailbox)).encode() or b""]
|
||||
|
||||
return "OK", [(b"1 (RFC822 {0}", self.mailbox[int(args[0])]), b")"]
|
||||
|
||||
|
||||
|
||||
def logout(self):
|
||||
|
||||
return "BYE", []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class EmailPollTests(TestCase):
|
||||
|
||||
def _poll(self, integration, fake):
|
||||
|
||||
with mock.patch.object(email_transport, "_imap_connect", return_value=fake):
|
||||
|
||||
return email_transport.poll_updates(integration)
|
||||
|
||||
|
||||
|
||||
def test_first_run_sets_cursor_without_ingesting_history(self) -> None:
|
||||
|
||||
fake = _FakeImap(validity=7, next_uid=100, mailbox={98: _raw(), 99: _raw()})
|
||||
|
||||
messages, marker = self._poll(_integration(poll_marker=""), fake)
|
||||
|
||||
self.assertEqual(messages, [])
|
||||
|
||||
self.assertEqual(marker, "7:99")
|
||||
|
||||
|
||||
|
||||
def test_new_mail_after_cursor_is_ingested(self) -> None:
|
||||
|
||||
fake = _FakeImap(validity=7, next_uid=101, mailbox={99: _raw(message_id="<old@example.com>"), 100: _raw()})
|
||||
|
||||
messages, marker = self._poll(_integration(poll_marker="7:99"), fake)
|
||||
|
||||
self.assertEqual([m.external_id for m in messages], ["<m1@example.com>"])
|
||||
|
||||
self.assertEqual(marker, "7:100")
|
||||
|
||||
|
||||
|
||||
def test_uidvalidity_change_resets_cursor(self) -> None:
|
||||
|
||||
fake = _FakeImap(validity=8, next_uid=50, mailbox={49: _raw()})
|
||||
|
||||
messages, marker = self._poll(_integration(poll_marker="7:99"), fake)
|
||||
|
||||
self.assertEqual(messages, [])
|
||||
|
||||
self.assertEqual(marker, "8:49")
|
||||
|
||||
|
||||
|
||||
def test_connection_error_keeps_cursor(self) -> None:
|
||||
|
||||
integration = _integration(poll_marker="7:99")
|
||||
|
||||
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
|
||||
|
||||
messages, marker = email_transport.poll_updates(integration)
|
||||
|
||||
self.assertEqual(messages, [])
|
||||
|
||||
self.assertEqual(marker, "7:99")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class EmailSendTests(TestCase):
|
||||
|
||||
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="edevs", name="Edevs — главный сайт")
|
||||
|
||||
self.integration = _integration(organization=self.organization, channel=self.channel)
|
||||
|
||||
self.integration.save()
|
||||
|
||||
contact = Contact.objects.create(organization=self.organization, name="Иван")
|
||||
|
||||
self.conversation = Conversation.objects.create(
|
||||
|
||||
organization=self.organization,
|
||||
|
||||
channel=self.channel,
|
||||
|
||||
connection=self.integration,
|
||||
|
||||
contact=contact,
|
||||
|
||||
external_chat_id="ivan@example.com",
|
||||
|
||||
transport_meta={"subject": "Вопрос по FoxRay", "last_message_id": "<m1@example.com>"},
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_reply_goes_into_the_same_thread(self) -> None:
|
||||
|
||||
with mock.patch.object(email_transport.smtplib, "SMTP_SSL") as smtp_cls:
|
||||
|
||||
ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Добрый день!")
|
||||
|
||||
self.assertTrue(ok)
|
||||
|
||||
smtp = smtp_cls.return_value
|
||||
|
||||
smtp.login.assert_called_once_with("support@example.com", "app-password")
|
||||
|
||||
outgoing = smtp.send_message.call_args.args[0]
|
||||
|
||||
self.assertEqual(outgoing["To"], "ivan@example.com")
|
||||
|
||||
self.assertEqual(outgoing["Subject"], "Re: Вопрос по FoxRay")
|
||||
|
||||
self.assertEqual(outgoing["In-Reply-To"], "<m1@example.com>")
|
||||
|
||||
self.assertEqual(outgoing["References"], "<m1@example.com>")
|
||||
|
||||
|
||||
|
||||
def test_send_without_thread_meta_uses_fallback_subject(self) -> None:
|
||||
|
||||
self.conversation.transport_meta = {}
|
||||
|
||||
self.conversation.save(update_fields=["transport_meta"])
|
||||
|
||||
with mock.patch.object(email_transport.smtplib, "SMTP_SSL") as smtp_cls:
|
||||
|
||||
ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Добрый день!")
|
||||
|
||||
self.assertTrue(ok)
|
||||
|
||||
outgoing = smtp_cls.return_value.send_message.call_args.args[0]
|
||||
|
||||
self.assertEqual(outgoing["Subject"], "Re: Ваше обращение")
|
||||
|
||||
self.assertIsNone(outgoing["In-Reply-To"])
|
||||
|
||||
|
||||
|
||||
def test_smtp_failure_returns_false(self) -> None:
|
||||
|
||||
with mock.patch.object(email_transport.smtplib, "SMTP_SSL", side_effect=OSError("refused")):
|
||||
|
||||
ok = email_transport.send_text(self.integration, chat_id="ivan@example.com", user_id="", text="Привет")
|
||||
|
||||
self.assertFalse(ok)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class EmailIngestThreadMetaTests(TestCase):
|
||||
|
||||
"""ingest пишет transport_meta: тема — от первого письма, Message-ID — последний."""
|
||||
|
||||
|
||||
|
||||
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="edevs", name="Edevs — главный сайт")
|
||||
|
||||
self.integration = _integration(organization=self.organization, channel=self.channel)
|
||||
|
||||
self.integration.save()
|
||||
|
||||
|
||||
|
||||
def _ingest(self, *, external_id: str, subject: str, message_id: str) -> None:
|
||||
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
|
||||
|
||||
|
||||
inbound = InboundMessage(
|
||||
|
||||
external_id=external_id,
|
||||
|
||||
user_id="ivan@example.com",
|
||||
|
||||
chat_id="ivan@example.com",
|
||||
|
||||
text="Здравствуйте!",
|
||||
|
||||
display_name="Иван",
|
||||
|
||||
thread_meta={"subject": subject, "last_message_id": message_id},
|
||||
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
def test_subject_pinned_to_first_message_id_follows_last(self) -> None:
|
||||
|
||||
self._ingest(external_id="<m1@example.com>", subject="Вопрос по FoxRay", message_id="<m1@example.com>")
|
||||
|
||||
self._ingest(external_id="<m2@example.com>", subject="Re: Вопрос по FoxRay", message_id="<m2@example.com>")
|
||||
|
||||
conversation = Conversation.objects.get(channel=self.channel)
|
||||
|
||||
self.assertEqual(conversation.transport_meta["subject"], "Вопрос по FoxRay")
|
||||
|
||||
self.assertEqual(conversation.transport_meta["last_message_id"], "<m2@example.com>")
|
||||
|
||||
authors = list(conversation.messages.values_list("author_type", flat=True))
|
||||
|
||||
self.assertIn(MessageAuthor.CONTACT, authors)
|
||||
|
||||
|
||||
|
||||
def test_email_identity_is_exposed_in_dialog_and_contact_payloads(self) -> None:
|
||||
|
||||
self._ingest(
|
||||
|
||||
external_id="<identity@example.com>",
|
||||
|
||||
subject="Контакты",
|
||||
|
||||
message_id="<identity@example.com>",
|
||||
|
||||
)
|
||||
|
||||
conversation = Conversation.objects.get(channel=self.channel)
|
||||
|
||||
dialog = conversation_payload(conversation, with_messages=True)
|
||||
|
||||
self.assertEqual(dialog["connection"]["provider"], "EMAIL")
|
||||
|
||||
self.assertEqual(dialog["contact"]["email"], "ivan@example.com")
|
||||
|
||||
self.assertEqual(dialog["messages"][0]["contentHtml"], "")
|
||||
|
||||
|
||||
|
||||
overview = clients_overview(self.organization.id)
|
||||
|
||||
self.assertEqual(overview[0]["email"], "ivan@example.com")
|
||||
|
||||
self.assertIn("EMAIL", overview[0]["channels"])
|
||||
|
||||
|
||||
|
||||
detail = client_detail(self.organization.id, conversation.contact_id)
|
||||
|
||||
self.assertEqual(detail["email"], "ivan@example.com")
|
||||
|
||||
self.assertEqual(detail["identities"][0]["value"], "ivan@example.com")
|
||||
|
||||
@@ -1,190 +1,380 @@
|
||||
import json␍
|
||||
from unittest import mock␍
|
||||
␍
|
||||
from django.test import TestCase␍
|
||||
␍
|
||||
from chatballs.channels.models import Channel␍
|
||||
from chatballs.conversations.ingest import ingest_inbound␍
|
||||
from chatballs.conversations.models import (␍
|
||||
ConnectionIdentity,␍
|
||||
Contact,␍
|
||||
ControlMode,␍
|
||||
Conversation,␍
|
||||
ExpectedResponder,␍
|
||||
LifecycleState,␍
|
||||
MessageAuthor,␍
|
||||
)␍
|
||||
from chatballs.conversations.transports.base import InboundMessage␍
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner␍
|
||||
from chatballs.identity.models import HumanUser, Organization␍
|
||||
from chatballs.integrations.models import (␍
|
||||
Integration,␍
|
||||
IntegrationKind,␍
|
||||
IntegrationProvider,␍
|
||||
)␍
|
||||
from chatballs.testing import TenantAPIClient as APIClient␍
|
||||
␍
|
||||
␍
|
||||
def _connection(channel: Channel) -> Integration:␍
|
||||
return Integration.objects.create(␍
|
||||
organization=channel.organization,␍
|
||||
kind=IntegrationKind.MESSENGER,␍
|
||||
provider=IntegrationProvider.TELEGRAM,␍
|
||||
name="test-bot",␍
|
||||
channel=channel,␍
|
||||
)␍
|
||||
␍
|
||||
␍
|
||||
class OperatorOnlyIngestTests(TestCase):␍
|
||||
def setUp(self) -> None:␍
|
||||
bootstrap_edevs_owner(␍
|
||||
email="owner@edevs.tech", password="temporary-password"␍
|
||||
)␍
|
||||
self.organization = Organization.objects.get(slug="edevs")␍
|
||||
self.channel = Channel.objects.create(␍
|
||||
organization=self.organization,␍
|
||||
code="operator-only",␍
|
||||
name="Операторский канал",␍
|
||||
)␍
|
||||
self.integration = _connection(self.channel)␍
|
||||
␍
|
||||
def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[mock.Mock, mock.Mock]:␍
|
||||
with (␍
|
||||
mock.patch(␍
|
||||
"chatballs.conversations.ingest.run_channel_turn"␍
|
||||
) as ai_turn,␍
|
||||
mock.patch(␍
|
||||
"chatballs.conversations.ingest.transports.send_reply"␍
|
||||
) as send,␍
|
||||
):␍
|
||||
ingest_inbound(self.integration, inbound)␍
|
||||
return ai_turn, send␍
|
||||
␍
|
||||
def test_new_dialog_starts_in_queue_without_ai_fallback(self) -> None:␍
|
||||
ai_turn, send = self._ingest_without_ai(␍
|
||||
InboundMessage(␍
|
||||
external_id="operator-1",␍
|
||||
user_id="user-1",␍
|
||||
chat_id="chat-1",␍
|
||||
text="Нужна помощь",␍
|
||||
display_name="Гость",␍
|
||||
)␍
|
||||
)␍
|
||||
␍
|
||||
conversation = self.channel.conversations.get()␍
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)␍
|
||||
self.assertEqual(␍
|
||||
conversation.expected_responder, ExpectedResponder.OPERATOR␍
|
||||
)␍
|
||||
self.assertEqual(␍
|
||||
list(conversation.messages.values_list("author_type", flat=True)),␍
|
||||
[MessageAuthor.CONTACT],␍
|
||||
)␍
|
||||
ai_turn.assert_not_called()␍
|
||||
send.assert_not_called()␍
|
||||
␍
|
||||
def test_existing_ai_dialog_moves_to_queue_when_agent_is_unavailable(self) -> None:␍
|
||||
contact = Contact.objects.create(␍
|
||||
organization=self.organization, name="Клиент"␍
|
||||
)␍
|
||||
ConnectionIdentity.objects.create(␍
|
||||
contact=contact,␍
|
||||
connection=self.integration,␍
|
||||
external_user_id="user-existing",␍
|
||||
)␍
|
||||
conversation = Conversation.objects.create(␍
|
||||
organization=self.organization,␍
|
||||
channel=self.channel,␍
|
||||
connection=self.integration,␍
|
||||
contact=contact,␍
|
||||
external_chat_id="chat-existing",␍
|
||||
control_mode=ControlMode.AI,␍
|
||||
expected_responder=ExpectedResponder.AI,␍
|
||||
)␍
|
||||
␍
|
||||
ai_turn, send = self._ingest_without_ai(␍
|
||||
InboundMessage(␍
|
||||
external_id="operator-2",␍
|
||||
user_id="user-existing",␍
|
||||
chat_id="chat-existing",␍
|
||||
text="Вы здесь?",␍
|
||||
display_name="Клиент",␍
|
||||
)␍
|
||||
)␍
|
||||
␍
|
||||
conversation.refresh_from_db()␍
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)␍
|
||||
self.assertEqual(␍
|
||||
conversation.expected_responder, ExpectedResponder.OPERATOR␍
|
||||
)␍
|
||||
self.assertEqual(conversation.messages.count(), 1)␍
|
||||
ai_turn.assert_not_called()␍
|
||||
send.assert_not_called()␍
|
||||
␍
|
||||
␍
|
||||
class ClosedConversationActionTests(TestCase):␍
|
||||
def setUp(self) -> None:␍
|
||||
bootstrap_edevs_owner(␍
|
||||
email="owner@edevs.tech", password="temporary-password"␍
|
||||
)␍
|
||||
organization = Organization.objects.get(slug="edevs")␍
|
||||
channel = Channel.objects.create(␍
|
||||
organization=organization,␍
|
||||
code="closed-actions",␍
|
||||
name="Закрытые диалоги",␍
|
||||
)␍
|
||||
integration = _connection(channel)␍
|
||||
contact = Contact.objects.create(organization=organization, name="Иван")␍
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")␍
|
||||
self.conversation = Conversation.objects.create(␍
|
||||
organization=organization,␍
|
||||
channel=channel,␍
|
||||
connection=integration,␍
|
||||
contact=contact,␍
|
||||
lifecycle=LifecycleState.CLOSED,␍
|
||||
control_mode=ControlMode.HUMAN,␍
|
||||
assigned_operator=owner,␍
|
||||
expected_responder=ExpectedResponder.NOBODY,␍
|
||||
)␍
|
||||
self.client = APIClient()␍
|
||||
self.client.login(␍
|
||||
username="owner@edevs.tech", password="temporary-password"␍
|
||||
)␍
|
||||
␍
|
||||
def test_closed_dialog_rejects_operational_actions(self) -> None:␍
|
||||
base = f"/api/v1/conversations/{self.conversation.id}"␍
|
||||
self.assertEqual(self.client.post(f"{base}/claim/").status_code, 409)␍
|
||||
self.assertEqual(self.client.post(f"{base}/release/").status_code, 409)␍
|
||||
self.assertEqual(␍
|
||||
self.client.post(f"{base}/return-queue/").status_code, 409␍
|
||||
)␍
|
||||
self.assertEqual(␍
|
||||
self.client.post(␍
|
||||
f"{base}/messages/",␍
|
||||
data=json.dumps({"text": "Поздний ответ"}),␍
|
||||
content_type="application/json",␍
|
||||
).status_code,␍
|
||||
409,␍
|
||||
)␍
|
||||
self.assertEqual(self.client.post(f"{base}/close/").status_code, 409)␍
|
||||
self.assertEqual(self.client.post(f"{base}/spam/").status_code, 409)␍
|
||||
␍
|
||||
def test_open_dialog_can_be_marked_as_spam(self) -> None:␍
|
||||
self.conversation.lifecycle = LifecycleState.OPEN␍
|
||||
self.conversation.expected_responder = ExpectedResponder.OPERATOR␍
|
||||
self.conversation.save(␍
|
||||
update_fields=["lifecycle", "expected_responder"]␍
|
||||
)␍
|
||||
␍
|
||||
response = self.client.post(␍
|
||||
f"/api/v1/conversations/{self.conversation.id}/spam/"␍
|
||||
)␍
|
||||
␍
|
||||
self.assertEqual(response.status_code, 200)␍
|
||||
payload = response.json()["conversation"]␍
|
||||
self.assertEqual(payload["lifecycle"], LifecycleState.SPAM)␍
|
||||
self.assertEqual(payload["controlMode"], ControlMode.PAUSED)␍
|
||||
self.assertIsNone(payload["assignedOperator"])␍
|
||||
self.assertEqual(␍
|
||||
payload["expectedResponder"], ExpectedResponder.NOBODY␍
|
||||
)␍
|
||||
import json
|
||||
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
from chatballs.conversations.models import (
|
||||
|
||||
ConnectionIdentity,
|
||||
|
||||
Contact,
|
||||
|
||||
ControlMode,
|
||||
|
||||
Conversation,
|
||||
|
||||
ExpectedResponder,
|
||||
|
||||
LifecycleState,
|
||||
|
||||
MessageAuthor,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
|
||||
from chatballs.identity.models import HumanUser, Organization
|
||||
|
||||
from chatballs.integrations.models import (
|
||||
|
||||
Integration,
|
||||
|
||||
IntegrationKind,
|
||||
|
||||
IntegrationProvider,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _connection(channel: Channel) -> Integration:
|
||||
|
||||
return Integration.objects.create(
|
||||
|
||||
organization=channel.organization,
|
||||
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
|
||||
name="test-bot",
|
||||
|
||||
channel=channel,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class OperatorOnlyIngestTests(TestCase):
|
||||
|
||||
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="operator-only",
|
||||
|
||||
name="Операторский канал",
|
||||
|
||||
)
|
||||
|
||||
self.integration = _connection(self.channel)
|
||||
|
||||
|
||||
|
||||
def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[mock.Mock, mock.Mock]:
|
||||
|
||||
with (
|
||||
|
||||
mock.patch(
|
||||
|
||||
"chatballs.conversations.ingest.run_channel_turn"
|
||||
|
||||
) as ai_turn,
|
||||
|
||||
mock.patch(
|
||||
|
||||
"chatballs.conversations.ingest.transports.send_reply"
|
||||
|
||||
) as send,
|
||||
|
||||
):
|
||||
|
||||
ingest_inbound(self.integration, inbound)
|
||||
|
||||
return ai_turn, send
|
||||
|
||||
|
||||
|
||||
def test_new_dialog_starts_in_queue_without_ai_fallback(self) -> None:
|
||||
|
||||
ai_turn, send = self._ingest_without_ai(
|
||||
|
||||
InboundMessage(
|
||||
|
||||
external_id="operator-1",
|
||||
|
||||
user_id="user-1",
|
||||
|
||||
chat_id="chat-1",
|
||||
|
||||
text="Нужна помощь",
|
||||
|
||||
display_name="Гость",
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
conversation = self.channel.conversations.get()
|
||||
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
|
||||
self.assertEqual(
|
||||
|
||||
conversation.expected_responder, ExpectedResponder.OPERATOR
|
||||
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
|
||||
list(conversation.messages.values_list("author_type", flat=True)),
|
||||
|
||||
[MessageAuthor.CONTACT],
|
||||
|
||||
)
|
||||
|
||||
ai_turn.assert_not_called()
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
|
||||
|
||||
def test_existing_ai_dialog_moves_to_queue_when_agent_is_unavailable(self) -> None:
|
||||
|
||||
contact = Contact.objects.create(
|
||||
|
||||
organization=self.organization, name="Клиент"
|
||||
|
||||
)
|
||||
|
||||
ConnectionIdentity.objects.create(
|
||||
|
||||
contact=contact,
|
||||
|
||||
connection=self.integration,
|
||||
|
||||
external_user_id="user-existing",
|
||||
|
||||
)
|
||||
|
||||
conversation = Conversation.objects.create(
|
||||
|
||||
organization=self.organization,
|
||||
|
||||
channel=self.channel,
|
||||
|
||||
connection=self.integration,
|
||||
|
||||
contact=contact,
|
||||
|
||||
external_chat_id="chat-existing",
|
||||
|
||||
control_mode=ControlMode.AI,
|
||||
|
||||
expected_responder=ExpectedResponder.AI,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
ai_turn, send = self._ingest_without_ai(
|
||||
|
||||
InboundMessage(
|
||||
|
||||
external_id="operator-2",
|
||||
|
||||
user_id="user-existing",
|
||||
|
||||
chat_id="chat-existing",
|
||||
|
||||
text="Вы здесь?",
|
||||
|
||||
display_name="Клиент",
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
conversation.refresh_from_db()
|
||||
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
|
||||
self.assertEqual(
|
||||
|
||||
conversation.expected_responder, ExpectedResponder.OPERATOR
|
||||
|
||||
)
|
||||
|
||||
self.assertEqual(conversation.messages.count(), 1)
|
||||
|
||||
ai_turn.assert_not_called()
|
||||
|
||||
send.assert_not_called()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ClosedConversationActionTests(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
|
||||
bootstrap_owner(
|
||||
|
||||
email="owner@example.com", password="temporary-password"
|
||||
|
||||
)
|
||||
|
||||
organization = Organization.objects.get(slug="demo")
|
||||
|
||||
channel = Channel.objects.create(
|
||||
|
||||
organization=organization,
|
||||
|
||||
code="closed-actions",
|
||||
|
||||
name="Закрытые диалоги",
|
||||
|
||||
)
|
||||
|
||||
integration = _connection(channel)
|
||||
|
||||
contact = Contact.objects.create(organization=organization, name="Иван")
|
||||
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
|
||||
self.conversation = Conversation.objects.create(
|
||||
|
||||
organization=organization,
|
||||
|
||||
channel=channel,
|
||||
|
||||
connection=integration,
|
||||
|
||||
contact=contact,
|
||||
|
||||
lifecycle=LifecycleState.CLOSED,
|
||||
|
||||
control_mode=ControlMode.HUMAN,
|
||||
|
||||
assigned_operator=owner,
|
||||
|
||||
expected_responder=ExpectedResponder.NOBODY,
|
||||
|
||||
)
|
||||
|
||||
self.client = APIClient()
|
||||
|
||||
self.client.login(
|
||||
|
||||
username="owner@example.com", password="temporary-password"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_closed_dialog_rejects_operational_actions(self) -> None:
|
||||
|
||||
base = f"/api/v1/conversations/{self.conversation.id}"
|
||||
|
||||
self.assertEqual(self.client.post(f"{base}/claim/").status_code, 409)
|
||||
|
||||
self.assertEqual(self.client.post(f"{base}/release/").status_code, 409)
|
||||
|
||||
self.assertEqual(
|
||||
|
||||
self.client.post(f"{base}/return-queue/").status_code, 409
|
||||
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
|
||||
self.client.post(
|
||||
|
||||
f"{base}/messages/",
|
||||
|
||||
data=json.dumps({"text": "Поздний ответ"}),
|
||||
|
||||
content_type="application/json",
|
||||
|
||||
).status_code,
|
||||
|
||||
409,
|
||||
|
||||
)
|
||||
|
||||
self.assertEqual(self.client.post(f"{base}/close/").status_code, 409)
|
||||
|
||||
self.assertEqual(self.client.post(f"{base}/spam/").status_code, 409)
|
||||
|
||||
|
||||
|
||||
def test_open_dialog_can_be_marked_as_spam(self) -> None:
|
||||
|
||||
self.conversation.lifecycle = LifecycleState.OPEN
|
||||
|
||||
self.conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
|
||||
self.conversation.save(
|
||||
|
||||
update_fields=["lifecycle", "expected_responder"]
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
response = self.client.post(
|
||||
|
||||
f"/api/v1/conversations/{self.conversation.id}/spam/"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
payload = response.json()["conversation"]
|
||||
|
||||
self.assertEqual(payload["lifecycle"], LifecycleState.SPAM)
|
||||
|
||||
self.assertEqual(payload["controlMode"], ControlMode.PAUSED)
|
||||
|
||||
self.assertIsNone(payload["assignedOperator"])
|
||||
|
||||
self.assertEqual(
|
||||
|
||||
payload["expectedResponder"], ExpectedResponder.NOBODY
|
||||
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from chatballs.conversations.models import (
|
||||
TranscriptStatus,
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
@@ -37,8 +37,8 @@ class VoiceTestCase(TestCase):
|
||||
расшифровка через BYOK, отправка оператором в Telegram и MAX."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
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="Линия"
|
||||
)
|
||||
@@ -51,7 +51,7 @@ class VoiceTestCase(TestCase):
|
||||
channel=self.channel,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def _voice_inbound(self, external_id: str = "v-1") -> InboundMessage:
|
||||
return InboundMessage(
|
||||
@@ -127,7 +127,7 @@ class VoiceApiTests(VoiceTestCase):
|
||||
self.assertEqual(response.headers["Content-Type"], "audio/ogg")
|
||||
|
||||
outsider = HumanUser.objects.create_user(
|
||||
email="stranger@edevs.tech", password="Password-123"
|
||||
email="stranger@example.com", password="Password-123"
|
||||
)
|
||||
other = Organization.objects.create(name="Other", slug="voice-other")
|
||||
OrganizationMembership.objects.create(
|
||||
@@ -168,7 +168,7 @@ class VoiceApiTests(VoiceTestCase):
|
||||
message = self._voice_message()
|
||||
conversation = message.conversation
|
||||
conversation.control_mode = ControlMode.HUMAN
|
||||
conversation.assigned_operator = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
conversation.assigned_operator = HumanUser.objects.get(email="owner@example.com")
|
||||
conversation.external_chat_id = "c-1"
|
||||
conversation.save(
|
||||
update_fields=["control_mode", "assigned_operator", "external_chat_id"]
|
||||
|
||||
@@ -22,7 +22,7 @@ from chatballs.conversations.models import (
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.conversations.transports import max as max_transport
|
||||
from chatballs.conversations.transports import telegram as telegram_transport
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
@@ -50,9 +50,9 @@ class IngestLimitHandlingTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.channel = Channel.objects.create(organization=self.organization, code="foxray-sales", name="FoxRay — продажи")
|
||||
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="FoxRay — продажи")
|
||||
AIAgent.objects.create(
|
||||
channel=self.channel,
|
||||
name="FoxRay Agent",
|
||||
@@ -177,9 +177,9 @@ class ContactShareIngestTests(TestCase):
|
||||
клиенту уходит подтверждение (в TG — со снятием клавиатуры)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.channel = Channel.objects.create(organization=self.organization, code="foxray-sales", name="FoxRay — продажи")
|
||||
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="FoxRay — продажи")
|
||||
AIAgent.objects.create(
|
||||
channel=self.channel,
|
||||
name="FoxRay Agent",
|
||||
@@ -231,9 +231,9 @@ class RequestContactApiTests(TestCase):
|
||||
и отправка кнопки в мессенджер; повторный запрос при известном телефоне — 409."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.channel = Channel.objects.create(organization=self.organization, code="foxray-sales", name="FoxRay — продажи")
|
||||
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="FoxRay — продажи")
|
||||
self.integration = _messenger_connection(self.channel)
|
||||
self.contact = Contact.objects.create(organization=self.organization, name="Иван")
|
||||
ConnectionIdentity.objects.create(
|
||||
@@ -244,7 +244,7 @@ class RequestContactApiTests(TestCase):
|
||||
contact=self.contact, external_chat_id="c1",
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
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:
|
||||
@@ -279,9 +279,9 @@ class ConversationReadTests(TestCase):
|
||||
непрочитанных (pendingCount) в списке гаснет без ответа оператора."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.channel = Channel.objects.create(organization=self.organization, code="foxray-sales", name="FoxRay — продажи")
|
||||
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="FoxRay — продажи")
|
||||
self.integration = _messenger_connection(self.channel)
|
||||
contact = Contact.objects.create(organization=self.organization, name="Иван")
|
||||
self.conversation = Conversation.objects.create(
|
||||
@@ -289,7 +289,7 @@ class ConversationReadTests(TestCase):
|
||||
)
|
||||
Message.objects.create(conversation=self.conversation, author_type=MessageAuthor.CONTACT, text="Хорошо")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def _pending(self) -> int:
|
||||
items = self.client.get("/api/v1/conversations/").json()["items"]
|
||||
@@ -310,12 +310,12 @@ class ConversationSearchTests(TestCase):
|
||||
полнотекстово (russian, websearch)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
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@edevs.tech", password="temporary-password")
|
||||
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)
|
||||
@@ -346,8 +346,8 @@ class WebchatContactTests(TestCase):
|
||||
в переписке появляются kind=contact и подтверждение."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
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="edevs-web", name="Веб-чат")
|
||||
AIAgent.objects.create(
|
||||
channel=self.channel,
|
||||
@@ -402,7 +402,7 @@ class WebchatContactTests(TestCase):
|
||||
) -> None:
|
||||
# Агент без BYOK-интеграции: IntegrationNotConfigured (ProviderError)
|
||||
# переводит диалог оператору вместо 500 (ADR-HUB-0042 §3).
|
||||
admin = HumanUser.objects.create_user(email="admin@edevs.tech", password="temporary")
|
||||
admin = HumanUser.objects.create_user(email="admin@example.com", password="temporary")
|
||||
OrganizationMembership.objects.create(
|
||||
user=admin,
|
||||
organization=self.organization,
|
||||
@@ -437,7 +437,7 @@ class WebchatContactTests(TestCase):
|
||||
target_id=str(conversation.id),
|
||||
).values_list("recipient_user__email", flat=True)
|
||||
),
|
||||
{"owner@edevs.tech", "admin@edevs.tech"},
|
||||
{"owner@example.com", "admin@example.com"},
|
||||
)
|
||||
|
||||
def test_provider_error_hands_off_without_500(self) -> None:
|
||||
|
||||
@@ -23,11 +23,11 @@ class BootstrapResult:
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") -> BootstrapResult:
|
||||
def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> BootstrapResult:
|
||||
organization, _ = Organization.objects.get_or_create(
|
||||
slug="edevs",
|
||||
slug="demo",
|
||||
defaults={
|
||||
"name": "Edevs",
|
||||
"name": "Demo",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
@@ -43,7 +43,7 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
|
||||
support_group, _ = EmployeeGroup.objects.get_or_create(
|
||||
organization=organization, name="Поддержка"
|
||||
)
|
||||
for code, name in (("firepage", "FirePage"), ("foxray", "Foxray")):
|
||||
for code, name in (("site", "Сайт"), ("app", "Приложение")):
|
||||
Product.objects.get_or_create(organization=organization, code=code, defaults={"name": name})
|
||||
# Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов.
|
||||
|
||||
@@ -73,7 +73,7 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
|
||||
},
|
||||
)
|
||||
operator, created_operator = HumanUser.objects.get_or_create(
|
||||
email=HumanUser.objects.normalize_email("a.kotova@edevs.tech"),
|
||||
email=HumanUser.objects.normalize_email("staff.member@example.org"),
|
||||
defaults={
|
||||
"full_name": "Анна Котова",
|
||||
"is_staff": False,
|
||||
|
||||
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
# Existing organizations (e.g. Edevs) are treated as already active.
|
||||
# Existing organizations are treated as already active.
|
||||
migrations.RunSQL(
|
||||
sql="UPDATE identity_organization SET status = 'ACTIVE' WHERE status IS NULL;",
|
||||
reverse_sql=migrations.RunSQL.noop,
|
||||
|
||||
@@ -21,7 +21,7 @@ class MembershipMigrationTests(TransactionTestCase):
|
||||
"identity", "EmployeeAccessAssignment"
|
||||
)
|
||||
|
||||
organization = Organization.objects.create(name="Edevs", slug="edevs")
|
||||
organization = Organization.objects.create(name="Acme", slug="acme")
|
||||
owner_user = HumanUser.objects.create(email="owner@example.test", password="hash")
|
||||
employee_user = HumanUser.objects.create(email="employee@example.test", password="hash")
|
||||
owner = EmployeeProfile.objects.create(
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.utils.http import urlsafe_base64_encode
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.auth.totp_utils import _totp_code
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
@@ -28,28 +28,28 @@ _LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMe
|
||||
|
||||
|
||||
class BootstrapOwnerTests(TestCase):
|
||||
def test_bootstrap_creates_edevs_foundation(self) -> None:
|
||||
result = bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech",
|
||||
def test_bootstrap_creates_foundation(self) -> None:
|
||||
result = bootstrap_owner(
|
||||
email="owner@example.com",
|
||||
password="temporary-password",
|
||||
full_name="Owner",
|
||||
)
|
||||
|
||||
self.assertTrue(result.created_owner)
|
||||
self.assertEqual(Organization.objects.get().slug, "edevs")
|
||||
self.assertEqual(Organization.objects.get().slug, "demo")
|
||||
# Seed создаёт стартовые группы (ADR-HUB-0043).
|
||||
self.assertEqual(
|
||||
set(EmployeeGroup.objects.values_list("name", flat=True)),
|
||||
{"Операторы", "Поддержка"},
|
||||
)
|
||||
self.assertEqual(result.support_group.name, "Поддержка")
|
||||
self.assertEqual(set(Product.objects.values_list("code", flat=True)), {"firepage", "foxray"})
|
||||
self.assertEqual(set(Product.objects.values_list("code", flat=True)), {"site", "app"})
|
||||
self.assertEqual(result.owner.memberships.get().role, EmployeeRole.OWNER)
|
||||
# TOTP выключен по умолчанию (намеренно, локальная разработка).
|
||||
self.assertFalse(result.owner.memberships.get().totp_required)
|
||||
self.assertTrue(result.owner.is_staff)
|
||||
self.assertTrue(result.owner.is_superuser)
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
self.assertEqual(operator.full_name, "Анна Котова")
|
||||
self.assertEqual(operator.memberships.get().role, EmployeeRole.EMPLOYEE)
|
||||
self.assertEqual(operator.memberships.get().phone, "+7 916 245 14 02")
|
||||
@@ -60,8 +60,8 @@ class BootstrapOwnerTests(TestCase):
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.owner_bootstrapped").exists())
|
||||
|
||||
def test_bootstrap_is_idempotent_for_owner(self) -> None:
|
||||
first = bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
second = bootstrap_edevs_owner(email="owner@edevs.tech", password="another-password")
|
||||
first = bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
second = bootstrap_owner(email="owner@example.com", password="another-password")
|
||||
|
||||
self.assertTrue(first.created_owner)
|
||||
self.assertFalse(second.created_owner)
|
||||
@@ -70,9 +70,9 @@ class BootstrapOwnerTests(TestCase):
|
||||
self.assertEqual(Product.objects.count(), 2)
|
||||
|
||||
def test_bootstrap_promotes_existing_owner_to_django_admin_access(self) -> None:
|
||||
user = HumanUser.objects.create_user(email="owner@edevs.tech", password="temporary-password")
|
||||
user = HumanUser.objects.create_user(email="owner@example.com", password="temporary-password")
|
||||
|
||||
result = bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
result = bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(result.created_owner)
|
||||
@@ -82,11 +82,11 @@ class BootstrapOwnerTests(TestCase):
|
||||
|
||||
class PermissionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
|
||||
def test_owner_can_access_global_settings_and_sales_workspace(self) -> None:
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
membership = owner.memberships.get(organization=self.organization)
|
||||
|
||||
self.assertTrue(
|
||||
@@ -101,7 +101,7 @@ class PermissionTests(TestCase):
|
||||
)
|
||||
|
||||
def test_operator_cannot_access_global_settings(self) -> None:
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
membership = operator.memberships.get(organization=self.organization)
|
||||
self.assertFalse(
|
||||
authorize(membership, "settings.manage", ResourceScope(self.organization.id))
|
||||
@@ -117,13 +117,13 @@ class PermissionTests(TestCase):
|
||||
|
||||
class AuthEndpointTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.client = Client()
|
||||
|
||||
def test_login_returns_session_payload(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/login/",
|
||||
data=json.dumps({"email": "owner@edevs.tech", "password": "temporary-password"}),
|
||||
data=json.dumps({"email": "owner@example.com", "password": "temporary-password"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ class AuthEndpointTests(TestCase):
|
||||
membership = payload["user"]["memberships"][0]
|
||||
self.assertIn("groups", membership)
|
||||
self.assertIn("employees.manage_privileged", membership["capabilities"])
|
||||
self.assertEqual(membership["organizationName"], "Edevs")
|
||||
self.assertEqual(membership["organizationName"], "Demo")
|
||||
self.assertEqual(membership["role"], EmployeeRole.OWNER)
|
||||
|
||||
def test_session_sets_csrf_cookie_for_spa(self) -> None:
|
||||
@@ -148,7 +148,7 @@ class AuthEndpointTests(TestCase):
|
||||
def test_login_rejects_invalid_password(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/login/",
|
||||
data=json.dumps({"email": "owner@edevs.tech", "password": "wrong-password"}),
|
||||
data=json.dumps({"email": "owner@example.com", "password": "wrong-password"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@@ -158,10 +158,10 @@ class AuthEndpointTests(TestCase):
|
||||
def test_password_reset_request_enqueues_outbox_event(self) -> None:
|
||||
from chatballs.events.models import OutboxEvent
|
||||
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password-reset/request/",
|
||||
data=json.dumps({"email": "owner@edevs.tech"}),
|
||||
data=json.dumps({"email": "owner@example.com"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@@ -178,7 +178,7 @@ class AuthEndpointTests(TestCase):
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password-reset/request/",
|
||||
data=json.dumps({"email": "nobody@edevs.tech"}),
|
||||
data=json.dumps({"email": "nobody@example.com"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@@ -190,11 +190,11 @@ class AuthEndpointTests(TestCase):
|
||||
def test_password_reset_handler_sends_email(self) -> None:
|
||||
from chatballs.identity.event_handlers import handle_password_reset_requested
|
||||
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
handle_password_reset_requested({"userId": owner.id}, None)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].to, ["owner@edevs.tech"])
|
||||
self.assertEqual(mail.outbox[0].to, ["owner@example.com"])
|
||||
self.assertIn("reset-password", mail.outbox[0].body)
|
||||
|
||||
def _reset_link(self, email: str) -> tuple[str, str]:
|
||||
@@ -202,7 +202,7 @@ class AuthEndpointTests(TestCase):
|
||||
return urlsafe_base64_encode(force_bytes(user.pk)), default_token_generator.make_token(user)
|
||||
|
||||
def test_password_reset_confirm_sets_new_password(self) -> None:
|
||||
uid, token = self._reset_link("owner@edevs.tech")
|
||||
uid, token = self._reset_link("owner@example.com")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password-reset/confirm/",
|
||||
@@ -212,11 +212,11 @@ class AuthEndpointTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["ok"])
|
||||
self.assertTrue(HumanUser.objects.get(email="owner@edevs.tech").check_password("Fresh-Pass-99"))
|
||||
self.assertTrue(HumanUser.objects.get(email="owner@example.com").check_password("Fresh-Pass-99"))
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.password_reset_completed").exists())
|
||||
|
||||
def test_password_reset_confirm_rejects_invalid_token(self) -> None:
|
||||
uid, _ = self._reset_link("owner@edevs.tech")
|
||||
uid, _ = self._reset_link("owner@example.com")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password-reset/confirm/",
|
||||
@@ -225,10 +225,10 @@ class AuthEndpointTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(HumanUser.objects.get(email="owner@edevs.tech").check_password("Fresh-Pass-99"))
|
||||
self.assertFalse(HumanUser.objects.get(email="owner@example.com").check_password("Fresh-Pass-99"))
|
||||
|
||||
def test_password_reset_validate_reflects_token_state(self) -> None:
|
||||
uid, token = self._reset_link("owner@edevs.tech")
|
||||
uid, token = self._reset_link("owner@example.com")
|
||||
|
||||
valid = self.client.get(f"/api/v1/auth/password-reset/validate/?uid={uid}&token={token}")
|
||||
invalid = self.client.get(f"/api/v1/auth/password-reset/validate/?uid={uid}&token=bad-token")
|
||||
@@ -237,10 +237,10 @@ class AuthEndpointTests(TestCase):
|
||||
self.assertFalse(invalid.json()["valid"])
|
||||
|
||||
def test_change_temporary_password_clears_user_flag(self) -> None:
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
owner.must_change_password = True
|
||||
owner.save(update_fields=["must_change_password"])
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/change-temporary-password/",
|
||||
@@ -258,10 +258,10 @@ class AuthEndpointTests(TestCase):
|
||||
self.assertFalse(owner.must_change_password)
|
||||
|
||||
def test_change_temporary_password_rejects_weak_password(self) -> None:
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
owner.must_change_password = True
|
||||
owner.save(update_fields=["must_change_password"])
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/change-temporary-password/",
|
||||
@@ -274,23 +274,23 @@ class AuthEndpointTests(TestCase):
|
||||
self.assertTrue(owner.must_change_password)
|
||||
|
||||
def test_profile_update_changes_current_user_identity(self) -> None:
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/profile/update/",
|
||||
data=json.dumps({"fullName": "Иван Петров", "email": "ivan@edevs.tech"}),
|
||||
data=json.dumps({"fullName": "Иван Петров", "email": "ivan@example.com"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
owner = HumanUser.objects.get(id=response.json()["user"]["id"])
|
||||
self.assertEqual(owner.full_name, "Иван Петров")
|
||||
self.assertEqual(owner.email, "ivan@edevs.tech")
|
||||
self.assertEqual(owner.email, "ivan@example.com")
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.profile_updated").exists())
|
||||
|
||||
def test_profile_appearance_saves_theme_and_accent(self) -> None:
|
||||
"""Тема и акцент — глобальные настройки пользователя (SPEC-HUB-0031 §7)."""
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/profile/appearance/",
|
||||
@@ -302,7 +302,7 @@ class AuthEndpointTests(TestCase):
|
||||
payload = response.json()["user"]
|
||||
self.assertEqual(payload["uiTheme"], "DARK")
|
||||
self.assertEqual(payload["uiAccent"], "#0f9b8e")
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self.assertEqual(owner.ui_theme, "DARK")
|
||||
self.assertEqual(owner.ui_accent, "#0f9b8e")
|
||||
|
||||
@@ -324,7 +324,7 @@ class AuthEndpointTests(TestCase):
|
||||
self.assertEqual(rejected.status_code, 400)
|
||||
|
||||
def test_profile_password_changes_current_user_password(self) -> None:
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/profile/password/",
|
||||
@@ -333,19 +333,19 @@ class AuthEndpointTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self.assertTrue(owner.check_password("New-Profile-99"))
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.profile_password_changed").exists())
|
||||
|
||||
def test_profile_totp_start_marks_setup_required(self) -> None:
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
profile = owner.memberships.get()
|
||||
profile.totp_required = False
|
||||
profile.save(update_fields=["totp_required"])
|
||||
owner.totp_enabled = False
|
||||
owner.totp_secret = "JBSWY3DPEHPK3PXP"
|
||||
owner.save(update_fields=["totp_enabled", "totp_secret"])
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
response = self.client.post("/api/v1/auth/profile/totp/start/")
|
||||
|
||||
@@ -357,12 +357,12 @@ class AuthEndpointTests(TestCase):
|
||||
self.assertEqual(owner.totp_secret, "")
|
||||
|
||||
def test_profile_totp_disable_requires_password_and_revokes_other_sessions(self) -> None:
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
profile = owner.memberships.get()
|
||||
owner.totp_enabled = True
|
||||
owner.totp_secret = "JBSWY3DPEHPK3PXP"
|
||||
owner.save(update_fields=["totp_enabled", "totp_secret"])
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
other_session = SessionStore()
|
||||
other_session["_auth_user_id"] = str(owner.id)
|
||||
other_session["_auth_user_backend"] = "django.contrib.auth.backends.ModelBackend"
|
||||
@@ -385,11 +385,11 @@ class AuthEndpointTests(TestCase):
|
||||
|
||||
def test_totp_setup_and_confirm_enables_profile_totp(self) -> None:
|
||||
# TOTP по умолчанию не требуется; включаем требование, чтобы пройти setup→confirm.
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
profile = owner.memberships.get()
|
||||
profile.totp_required = True
|
||||
profile.save(update_fields=["totp_required"])
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
setup_response = self.client.get("/api/v1/auth/totp/setup/")
|
||||
|
||||
@@ -405,18 +405,18 @@ class AuthEndpointTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(confirm_response.status_code, 200)
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self.assertTrue(owner.totp_enabled)
|
||||
|
||||
def test_enabled_totp_requires_second_factor_before_session(self) -> None:
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
owner.totp_secret = "JBSWY3DPEHPK3PXP"
|
||||
owner.totp_enabled = True
|
||||
owner.save(update_fields=["totp_secret", "totp_enabled"])
|
||||
|
||||
login_response = self.client.post(
|
||||
"/api/v1/auth/login/",
|
||||
data=json.dumps({"email": "owner@edevs.tech", "password": "temporary-password"}),
|
||||
data=json.dumps({"email": "owner@example.com", "password": "temporary-password"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@@ -437,9 +437,9 @@ class AuthEndpointTests(TestCase):
|
||||
@override_settings(ROOT_URLCONF="chatballs_backend.urls_admin")
|
||||
class DjangoAdminTests(TestCase):
|
||||
def test_bootstrapped_owner_can_access_django_admin(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
client = Client()
|
||||
self.assertTrue(client.login(username="owner@edevs.tech", password="temporary-password"))
|
||||
self.assertTrue(client.login(username="owner@example.com", password="temporary-password"))
|
||||
|
||||
response = client.get("/admin/")
|
||||
|
||||
@@ -448,17 +448,17 @@ class DjangoAdminTests(TestCase):
|
||||
|
||||
class EmployeeEndpointTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def test_owner_cannot_create_operator_with_temporary_password(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/v1/employees/operators/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"email": "operator@edevs.tech",
|
||||
"email": "operator@example.com",
|
||||
"fullName": "Operator",
|
||||
"positionTitle": "Оператор продаж",
|
||||
"temporaryPassword": "operator-password",
|
||||
@@ -468,23 +468,23 @@ class EmployeeEndpointTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(HumanUser.objects.filter(email="operator@edevs.tech").exists())
|
||||
self.assertFalse(HumanUser.objects.filter(email="operator@example.com").exists())
|
||||
|
||||
def test_operator_cannot_create_operator(self) -> None:
|
||||
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
|
||||
operator = HumanUser.objects.create_user(email="operator@example.com", password="operator-password")
|
||||
OrganizationMembership.objects.create(
|
||||
user=operator,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
)
|
||||
self.client.logout()
|
||||
self.client.login(username="operator@edevs.tech", password="operator-password")
|
||||
self.client.login(username="operator@example.com", password="operator-password")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/employees/operators/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"email": "another@edevs.tech",
|
||||
"email": "another@example.com",
|
||||
"temporaryPassword": "operator-password",
|
||||
}
|
||||
),
|
||||
@@ -499,7 +499,7 @@ class EmployeeEndpointTests(TestCase):
|
||||
)
|
||||
|
||||
def test_owner_blocks_operator(self) -> None:
|
||||
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
|
||||
operator = HumanUser.objects.create_user(email="operator@example.com", password="operator-password")
|
||||
OrganizationMembership.objects.create(
|
||||
user=operator,
|
||||
organization=self.organization,
|
||||
@@ -515,14 +515,14 @@ class EmployeeEndpointTests(TestCase):
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_blocked").exists())
|
||||
|
||||
def test_owner_updates_operator_card_fields(self) -> None:
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/employees/{operator.id}/update/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"fullName": "Анна Котова",
|
||||
"email": "anna.kotova@edevs.tech",
|
||||
"email": "anna.kotova@example.com",
|
||||
"phone": "+7 916 245 14 03",
|
||||
"positionTitle": "Оператор продаж",
|
||||
"role": EmployeeRole.EMPLOYEE,
|
||||
@@ -535,12 +535,12 @@ class EmployeeEndpointTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
operator.refresh_from_db()
|
||||
operator.memberships.get().refresh_from_db()
|
||||
self.assertEqual(operator.email, "anna.kotova@edevs.tech")
|
||||
self.assertEqual(operator.email, "anna.kotova@example.com")
|
||||
self.assertEqual(operator.memberships.get().phone, "+7 916 245 14 03")
|
||||
self.assertFalse(operator.totp_enabled)
|
||||
|
||||
def test_owner_cannot_reset_operator_global_password(self) -> None:
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
password_hash = operator.password
|
||||
|
||||
response = self.client.post(f"/api/v1/employees/{operator.id}/reset-password/")
|
||||
@@ -551,7 +551,7 @@ class EmployeeEndpointTests(TestCase):
|
||||
self.assertFalse(operator.must_change_password)
|
||||
|
||||
def test_owner_unblocks_operator(self) -> None:
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
operator.memberships.get().block()
|
||||
|
||||
response = self.client.post(f"/api/v1/employees/{operator.id}/unblock/")
|
||||
@@ -565,10 +565,10 @@ class EmployeeEndpointTests(TestCase):
|
||||
|
||||
class CompanyEndpointTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def test_owner_reads_groups_and_products(self) -> None:
|
||||
groups_response = self.client.get("/api/v1/company/groups/")
|
||||
@@ -582,7 +582,7 @@ class CompanyEndpointTests(TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
set(product["code"] for product in products_response.json()["items"]),
|
||||
{"firepage", "foxray"},
|
||||
{"site", "app"},
|
||||
)
|
||||
|
||||
def test_owner_creates_and_deactivates_product(self) -> None:
|
||||
@@ -603,14 +603,14 @@ class CompanyEndpointTests(TestCase):
|
||||
self.assertTrue(AuditEvent.objects.filter(action="products.product_disabled").exists())
|
||||
|
||||
def test_operator_cannot_create_product(self) -> None:
|
||||
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
|
||||
operator = HumanUser.objects.create_user(email="operator@example.com", password="operator-password")
|
||||
OrganizationMembership.objects.create(
|
||||
user=operator,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
)
|
||||
self.client.logout()
|
||||
self.client.login(username="operator@edevs.tech", password="operator-password")
|
||||
self.client.login(username="operator@example.com", password="operator-password")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/company/products/create/",
|
||||
@@ -623,14 +623,14 @@ class CompanyEndpointTests(TestCase):
|
||||
|
||||
class TotpSecretEncryptionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
|
||||
def test_totp_secret_is_encrypted_at_rest_and_decrypted_on_load(self) -> None:
|
||||
from django.db import connection
|
||||
|
||||
from chatballs.identity.crypto import decrypt_secret
|
||||
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
owner.totp_secret = "JBSWY3DPEHPK3PXP"
|
||||
owner.save(update_fields=["totp_secret"])
|
||||
|
||||
@@ -648,13 +648,13 @@ class TotpSecretEncryptionTests(TestCase):
|
||||
@override_settings(CACHES=_LOCMEM_CACHE)
|
||||
class ThrottlingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.client = Client()
|
||||
|
||||
def _login(self):
|
||||
return self.client.post(
|
||||
"/api/v1/auth/login/",
|
||||
data=json.dumps({"email": "owner@edevs.tech", "password": "wrong"}),
|
||||
data=json.dumps({"email": "owner@example.com", "password": "wrong"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@@ -674,16 +674,16 @@ class EmployeeModelInvariantTests(TestCase):
|
||||
размещение владельца на уровне компании и ровно один владелец на организацию."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
|
||||
def test_bootstrap_owner_has_owner_role_and_title(self) -> None:
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self.assertEqual(owner.memberships.get().role, EmployeeRole.OWNER)
|
||||
self.assertTrue(owner.memberships.get().position_title)
|
||||
|
||||
def test_bootstrapped_operator_is_employee_in_operators_group(self) -> None:
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
self.assertEqual(operator.memberships.get().role, EmployeeRole.EMPLOYEE)
|
||||
self.assertEqual(
|
||||
[link.group.name for link in operator.memberships.get().group_links.all()],
|
||||
@@ -694,7 +694,7 @@ class EmployeeModelInvariantTests(TestCase):
|
||||
def test_second_owner_is_rejected(self) -> None:
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
second = HumanUser.objects.create_user(email="owner2@edevs.tech", password="temporary-password")
|
||||
second = HumanUser.objects.create_user(email="owner2@example.com", password="temporary-password")
|
||||
with self.assertRaises(IntegrityError):
|
||||
with transaction.atomic():
|
||||
OrganizationMembership.objects.create(
|
||||
@@ -706,19 +706,19 @@ class EmployeeModelInvariantTests(TestCase):
|
||||
|
||||
def test_create_employee_requires_position_title(self) -> None:
|
||||
client = APIClient()
|
||||
client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
client.login(username="owner@example.com", password="temporary-password")
|
||||
response = client.post(
|
||||
"/api/v1/employees/operators/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"email": "no-title@edevs.tech",
|
||||
"email": "no-title@example.com",
|
||||
"fullName": "No Title",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(HumanUser.objects.filter(email="no-title@edevs.tech").exists())
|
||||
self.assertFalse(HumanUser.objects.filter(email="no-title@example.com").exists())
|
||||
|
||||
|
||||
class EmployeeGovernanceTests(TestCase):
|
||||
@@ -726,8 +726,8 @@ class EmployeeGovernanceTests(TestCase):
|
||||
OWNER/ADMIN/EMPLOYEE, target-aware управление и передача владения."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
|
||||
def _make(self, email: str, role: str) -> HumanUser:
|
||||
user = HumanUser.objects.create_user(email=email, password="member-password-123")
|
||||
@@ -761,44 +761,44 @@ class EmployeeGovernanceTests(TestCase):
|
||||
# --- создание и назначение ролей ---
|
||||
|
||||
def test_owner_creates_admin(self) -> None:
|
||||
client = self._client("owner@edevs.tech", "temporary-password")
|
||||
response = self._create(client, "admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
client = self._client("owner@example.com", "temporary-password")
|
||||
response = self._create(client, "admin@example.com", EmployeeRole.ADMIN)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
profile = HumanUser.objects.get(email="admin@edevs.tech").memberships.get()
|
||||
profile = HumanUser.objects.get(email="admin@example.com").memberships.get()
|
||||
self.assertEqual(profile.role, EmployeeRole.ADMIN)
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_created").exists())
|
||||
|
||||
def test_admin_creates_admin(self) -> None:
|
||||
# SPEC-HUB-0031 §3: ADMIN идентичен OWNER и может создавать админов.
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
response = self._create(self._client("admin@edevs.tech"), "admin2@edevs.tech", EmployeeRole.ADMIN)
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
response = self._create(self._client("admin@example.com"), "admin2@example.com", EmployeeRole.ADMIN)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(
|
||||
HumanUser.objects.get(email="admin2@edevs.tech").memberships.get().role,
|
||||
HumanUser.objects.get(email="admin2@example.com").memberships.get().role,
|
||||
EmployeeRole.ADMIN,
|
||||
)
|
||||
|
||||
def test_admin_creates_employee(self) -> None:
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
response = self._create(self._client("admin@edevs.tech"), "emp@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
response = self._create(self._client("admin@example.com"), "emp@example.com", EmployeeRole.EMPLOYEE)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(
|
||||
HumanUser.objects.get(email="emp@edevs.tech").memberships.get().role,
|
||||
HumanUser.objects.get(email="emp@example.com").memberships.get().role,
|
||||
EmployeeRole.EMPLOYEE,
|
||||
)
|
||||
|
||||
def test_create_owner_via_flow_is_rejected(self) -> None:
|
||||
client = self._client("owner@edevs.tech", "temporary-password")
|
||||
response = self._create(client, "owner2@edevs.tech", EmployeeRole.OWNER)
|
||||
client = self._client("owner@example.com", "temporary-password")
|
||||
response = self._create(client, "owner2@example.com", EmployeeRole.OWNER)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(HumanUser.objects.filter(email="owner2@edevs.tech").exists())
|
||||
self.assertFalse(HumanUser.objects.filter(email="owner2@example.com").exists())
|
||||
|
||||
# --- target-aware управление ---
|
||||
|
||||
def test_admin_can_block_employee(self) -> None:
|
||||
emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@edevs.tech").post(f"/api/v1/employees/{emp.id}/block/")
|
||||
emp = self._make("emp@example.com", EmployeeRole.EMPLOYEE)
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@example.com").post(f"/api/v1/employees/{emp.id}/block/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
emp.refresh_from_db()
|
||||
self.assertTrue(emp.is_active)
|
||||
@@ -806,26 +806,26 @@ class EmployeeGovernanceTests(TestCase):
|
||||
|
||||
def test_admin_blocks_another_admin(self) -> None:
|
||||
# SPEC-HUB-0031 §3: админы управляют друг другом; защищён только владелец.
|
||||
other = self._make("admin2@edevs.tech", EmployeeRole.ADMIN)
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@edevs.tech").post(f"/api/v1/employees/{other.id}/block/")
|
||||
other = self._make("admin2@example.com", EmployeeRole.ADMIN)
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@example.com").post(f"/api/v1/employees/{other.id}/block/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(other.memberships.get().is_blocked)
|
||||
|
||||
def test_admin_cannot_block_owner(self) -> None:
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@edevs.tech").post(f"/api/v1/employees/{owner.id}/block/")
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@example.com").post(f"/api/v1/employees/{owner.id}/block/")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
owner.refresh_from_db()
|
||||
self.assertTrue(owner.is_active)
|
||||
|
||||
def test_admin_updates_another_admin(self) -> None:
|
||||
other = self._make("admin2@edevs.tech", EmployeeRole.ADMIN)
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@edevs.tech").post(
|
||||
other = self._make("admin2@example.com", EmployeeRole.ADMIN)
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@example.com").post(
|
||||
f"/api/v1/employees/{other.id}/update/",
|
||||
data=json.dumps({"fullName": "Renamed", "email": "admin2@edevs.tech", "positionTitle": "X"}),
|
||||
data=json.dumps({"fullName": "Renamed", "email": "admin2@example.com", "positionTitle": "X"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -833,12 +833,12 @@ class EmployeeGovernanceTests(TestCase):
|
||||
self.assertEqual(other.full_name, "Renamed")
|
||||
|
||||
def test_admin_promotes_employee_to_admin(self) -> None:
|
||||
emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@edevs.tech").post(
|
||||
emp = self._make("emp@example.com", EmployeeRole.EMPLOYEE)
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@example.com").post(
|
||||
f"/api/v1/employees/{emp.id}/update/",
|
||||
data=json.dumps(
|
||||
{"fullName": "Emp", "email": "emp@edevs.tech", "positionTitle": "X", "role": EmployeeRole.ADMIN}
|
||||
{"fullName": "Emp", "email": "emp@example.com", "positionTitle": "X", "role": EmployeeRole.ADMIN}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -847,11 +847,11 @@ class EmployeeGovernanceTests(TestCase):
|
||||
self.assertEqual(emp.memberships.get().role, EmployeeRole.ADMIN)
|
||||
|
||||
def test_owner_promotes_employee_to_admin(self) -> None:
|
||||
emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
response = self._client("owner@edevs.tech", "temporary-password").post(
|
||||
emp = self._make("emp@example.com", EmployeeRole.EMPLOYEE)
|
||||
response = self._client("owner@example.com", "temporary-password").post(
|
||||
f"/api/v1/employees/{emp.id}/update/",
|
||||
data=json.dumps(
|
||||
{"fullName": "Emp", "email": "emp@edevs.tech", "positionTitle": "X", "role": EmployeeRole.ADMIN}
|
||||
{"fullName": "Emp", "email": "emp@example.com", "positionTitle": "X", "role": EmployeeRole.ADMIN}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -861,9 +861,9 @@ class EmployeeGovernanceTests(TestCase):
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_role_changed").exists())
|
||||
|
||||
def test_employee_cannot_manage(self) -> None:
|
||||
emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
other = self._make("emp2@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
response = self._client("emp@edevs.tech").post(f"/api/v1/employees/{other.id}/block/")
|
||||
emp = self._make("emp@example.com", EmployeeRole.EMPLOYEE)
|
||||
other = self._make("emp2@example.com", EmployeeRole.EMPLOYEE)
|
||||
response = self._client("emp@example.com").post(f"/api/v1/employees/{other.id}/block/")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
emp.refresh_from_db()
|
||||
|
||||
@@ -873,7 +873,7 @@ class EmployeeGovernanceTests(TestCase):
|
||||
OrganizationMembership.objects.create(
|
||||
user=outsider, organization=other_org, role=EmployeeRole.EMPLOYEE, position_title="X"
|
||||
)
|
||||
response = self._client("owner@edevs.tech", "temporary-password").post(
|
||||
response = self._client("owner@example.com", "temporary-password").post(
|
||||
f"/api/v1/employees/{outsider.id}/block/"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -881,9 +881,9 @@ class EmployeeGovernanceTests(TestCase):
|
||||
# --- передача владения ---
|
||||
|
||||
def test_owner_transfers_ownership_atomically(self) -> None:
|
||||
target = self._make("heir@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
response = self._client("owner@edevs.tech", "temporary-password").post(
|
||||
target = self._make("heir@example.com", EmployeeRole.EMPLOYEE)
|
||||
owner = HumanUser.objects.get(email="owner@example.com")
|
||||
response = self._client("owner@example.com", "temporary-password").post(
|
||||
f"/api/v1/employees/{target.id}/transfer-ownership/",
|
||||
data=json.dumps({"previousOwnerRole": EmployeeRole.ADMIN}),
|
||||
content_type="application/json",
|
||||
@@ -900,9 +900,9 @@ class EmployeeGovernanceTests(TestCase):
|
||||
self.assertTrue(AuditEvent.objects.filter(action="identity.ownership_transferred").exists())
|
||||
|
||||
def test_admin_cannot_transfer_ownership(self) -> None:
|
||||
target = self._make("heir@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@edevs.tech").post(
|
||||
target = self._make("heir@example.com", EmployeeRole.EMPLOYEE)
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
response = self._client("admin@example.com").post(
|
||||
f"/api/v1/employees/{target.id}/transfer-ownership/",
|
||||
data=json.dumps({"previousOwnerRole": EmployeeRole.EMPLOYEE}),
|
||||
content_type="application/json",
|
||||
@@ -916,8 +916,8 @@ class EmployeeGovernanceTests(TestCase):
|
||||
# --- обычные capability ADMIN ---
|
||||
|
||||
def test_admin_has_normal_capabilities(self) -> None:
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
client = self._client("admin@edevs.tech")
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
client = self._client("admin@example.com")
|
||||
audit = client.get("/api/v1/company/administration/audit/")
|
||||
self.assertEqual(audit.status_code, 200)
|
||||
product = client.post(
|
||||
@@ -928,17 +928,17 @@ class EmployeeGovernanceTests(TestCase):
|
||||
self.assertEqual(product.status_code, 201)
|
||||
|
||||
def test_employee_denied_audit(self) -> None:
|
||||
self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
response = self._client("emp@edevs.tech").get("/api/v1/company/administration/audit/")
|
||||
self._make("emp@example.com", EmployeeRole.EMPLOYEE)
|
||||
response = self._client("emp@example.com").get("/api/v1/company/administration/audit/")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_payload_permissions_reflect_actor(self) -> None:
|
||||
self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
|
||||
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
|
||||
items = self._client("admin@edevs.tech").get("/api/v1/employees/").json()["items"]
|
||||
self._make("emp@example.com", EmployeeRole.EMPLOYEE)
|
||||
self._make("admin@example.com", EmployeeRole.ADMIN)
|
||||
items = self._client("admin@example.com").get("/api/v1/employees/").json()["items"]
|
||||
by_email = {item["email"]: item for item in items}
|
||||
# ADMIN идентичен OWNER: управляет всеми, кроме блокировки/удаления владельца.
|
||||
self.assertTrue(by_email["emp@edevs.tech"]["permissions"]["canBlock"])
|
||||
self.assertTrue(by_email["emp@edevs.tech"]["permissions"]["canChangeRole"])
|
||||
self.assertFalse(by_email["owner@edevs.tech"]["permissions"]["canBlock"])
|
||||
self.assertTrue(by_email["owner@edevs.tech"]["permissions"]["canUpdateProfile"])
|
||||
self.assertTrue(by_email["emp@example.com"]["permissions"]["canBlock"])
|
||||
self.assertTrue(by_email["emp@example.com"]["permissions"]["canChangeRole"])
|
||||
self.assertFalse(by_email["owner@example.com"]["permissions"]["canBlock"])
|
||||
self.assertTrue(by_email["owner@example.com"]["permissions"]["canUpdateProfile"])
|
||||
@@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.integrations.serializers import integration_payload
|
||||
@@ -16,10 +16,10 @@ from chatballs.testing import system_tenant_context
|
||||
|
||||
class IntegrationActivationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(
|
||||
email="owner@edevs.tech", password="temporary-password"
|
||||
bootstrap_owner(
|
||||
email="owner@example.com", password="temporary-password"
|
||||
)
|
||||
organization = Organization.objects.get(slug="edevs")
|
||||
organization = Organization.objects.get(slug="demo")
|
||||
self.context = system_tenant_context(organization)
|
||||
self.channel = Channel.objects.create(
|
||||
organization=organization,
|
||||
|
||||
@@ -1,137 +1,274 @@
|
||||
"""Email-подключение: конфигурация, проверка и сериализация (SPEC-HUB-0025 §5)."""␍
|
||||
␍
|
||||
from unittest import mock␍
|
||||
␍
|
||||
from django.core.exceptions import ValidationError␍
|
||||
from django.test import TestCase␍
|
||||
␍
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner␍
|
||||
from chatballs.identity.models import Organization␍
|
||||
from chatballs.integrations import checks␍
|
||||
from chatballs.integrations.models import IntegrationKind, IntegrationProvider, IntegrationStatus␍
|
||||
from chatballs.integrations.serializers import integration_payload␍
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration␍
|
||||
from chatballs.testing import system_tenant_context␍
|
||||
␍
|
||||
EMAIL_INPUT = {␍
|
||||
"email": "Support@edevs.tech",␍
|
||||
"imapHost": "imap.yandex.ru",␍
|
||||
"smtpHost": "smtp.yandex.ru",␍
|
||||
}␍
|
||||
␍
|
||||
␍
|
||||
class EmailConfigTests(TestCase):␍
|
||||
def setUp(self) -> None:␍
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")␍
|
||||
self.context = system_tenant_context(Organization.objects.get(slug="edevs"))␍
|
||||
␍
|
||||
def _create(self, *, secret="app-password", config=None):␍
|
||||
return create_integration(␍
|
||||
context=self.context,␍
|
||||
data=IntegrationInput(provider=IntegrationProvider.EMAIL, name="Почта", secret=secret, config=config or dict(EMAIL_INPUT)),␍
|
||||
)␍
|
||||
␍
|
||||
def test_email_is_messenger_kind_with_normalized_config(self) -> None:␍
|
||||
integration = self._create()␍
|
||||
self.assertEqual(integration.kind, IntegrationKind.MESSENGER)␍
|
||||
# camelCase → snake_case, адрес приводится к lowercase, порты/SSL по умолчанию.␍
|
||||
self.assertEqual(␍
|
||||
integration.config,␍
|
||||
{␍
|
||||
"email": "support@edevs.tech",␍
|
||||
"imap_host": "imap.yandex.ru",␍
|
||||
"imap_port": 993,␍
|
||||
"imap_ssl": True,␍
|
||||
"smtp_host": "smtp.yandex.ru",␍
|
||||
"smtp_port": 465,␍
|
||||
"smtp_ssl": True,␍
|
||||
},␍
|
||||
)␍
|
||||
␍
|
||||
def test_secret_required_on_create(self) -> None:␍
|
||||
with self.assertRaises(ValidationError):␍
|
||||
self._create(secret="")␍
|
||||
␍
|
||||
def test_hosts_and_address_required(self) -> None:␍
|
||||
with self.assertRaises(ValidationError):␍
|
||||
self._create(config={"email": "a@b.c", "imapHost": "imap.b.c"})␍
|
||||
␍
|
||||
def test_notifications_purpose_rejected(self) -> None:␍
|
||||
# Email не может быть сервисным ботом уведомлений (ADR-HUB-0035, границы).␍
|
||||
with self.assertRaises(ValidationError):␍
|
||||
self._create(config={**EMAIL_INPUT, "purpose": "notifications"})␍
|
||||
␍
|
||||
def test_serializer_exposes_email_config(self) -> None:␍
|
||||
payload = integration_payload(self._create())␍
|
||||
config = payload["config"]␍
|
||||
self.assertEqual(config["email"], "support@edevs.tech")␍
|
||||
self.assertEqual(config["imapHost"], "imap.yandex.ru")␍
|
||||
self.assertEqual(config["imapPort"], 993)␍
|
||||
self.assertTrue(config["smtpSsl"])␍
|
||||
self.assertTrue(payload["hasSecret"])␍
|
||||
␍
|
||||
␍
|
||||
class EmailCheckTests(TestCase):␍
|
||||
"""check_email: успех только когда успешны ОБЕ стороны (IMAP и SMTP)."""␍
|
||||
␍
|
||||
CONFIG = {␍
|
||||
"email": "support@edevs.tech",␍
|
||||
"imap_host": "imap.test",␍
|
||||
"imap_port": 993,␍
|
||||
"imap_ssl": True,␍
|
||||
"smtp_host": "smtp.test",␍
|
||||
"smtp_port": 465,␍
|
||||
"smtp_ssl": True,␍
|
||||
}␍
|
||||
␍
|
||||
def test_both_sides_ok(self) -> None:␍
|
||||
with (␍
|
||||
mock.patch.object(checks.imaplib, "IMAP4_SSL") as imap_cls,␍
|
||||
mock.patch.object(checks.smtplib, "SMTP_SSL") as smtp_cls,␍
|
||||
):␍
|
||||
ok, detail, meta = checks.check_email(secret="pw", config=self.CONFIG)␍
|
||||
self.assertTrue(ok)␍
|
||||
self.assertEqual(detail, "Email: support@edevs.tech")␍
|
||||
imap_cls.return_value.login.assert_called_once_with("support@edevs.tech", "pw")␍
|
||||
smtp_cls.return_value.login.assert_called_once_with("support@edevs.tech", "pw")␍
|
||||
␍
|
||||
def test_imap_failure_reported(self) -> None:␍
|
||||
with mock.patch.object(checks.imaplib, "IMAP4_SSL", side_effect=OSError("connection refused")):␍
|
||||
ok, detail, _ = checks.check_email(secret="pw", config=self.CONFIG)␍
|
||||
self.assertFalse(ok)␍
|
||||
self.assertIn("IMAP:", detail)␍
|
||||
␍
|
||||
def test_smtp_failure_reported(self) -> None:␍
|
||||
with (␍
|
||||
mock.patch.object(checks.imaplib, "IMAP4_SSL"),␍
|
||||
mock.patch.object(checks.smtplib, "SMTP_SSL", side_effect=OSError("connection refused")),␍
|
||||
):␍
|
||||
ok, detail, _ = checks.check_email(secret="pw", config=self.CONFIG)␍
|
||||
self.assertFalse(ok)␍
|
||||
self.assertIn("SMTP:", detail)␍
|
||||
␍
|
||||
def test_missing_secret_fails(self) -> None:␍
|
||||
ok, detail, _ = checks.check_email(secret="", config=self.CONFIG)␍
|
||||
self.assertFalse(ok)␍
|
||||
self.assertIn("пароль", detail)␍
|
||||
␍
|
||||
␍
|
||||
class EmailTestIntegrationDispatchTests(TestCase):␍
|
||||
"""test_integration диспетчеризует EMAIL на check_email (полный config)."""␍
|
||||
␍
|
||||
def setUp(self) -> None:␍
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")␍
|
||||
self.context = system_tenant_context(Organization.objects.get(slug="edevs"))␍
|
||||
␍
|
||||
def test_email_check_updates_status(self) -> None:␍
|
||||
from chatballs.integrations.services import test_integration as run_integration_test␍
|
||||
␍
|
||||
integration = create_integration(␍
|
||||
context=self.context,␍
|
||||
data=IntegrationInput(provider=IntegrationProvider.EMAIL, name="Почта", secret="pw", config=dict(EMAIL_INPUT)),␍
|
||||
)␍
|
||||
with mock.patch.object(checks, "check_email", return_value=(True, "Email: support@edevs.tech", {})) as check:␍
|
||||
checked = run_integration_test(context=self.context, integration=integration)␍
|
||||
check.assert_called_once()␍
|
||||
self.assertEqual(checked.status, IntegrationStatus.OK)␍
|
||||
self.assertEqual(checked.last_error, "")␍
|
||||
"""Email-подключение: конфигурация, проверка и сериализация (SPEC-HUB-0025 §5)."""
|
||||
|
||||
|
||||
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
from chatballs.integrations import checks
|
||||
|
||||
from chatballs.integrations.models import IntegrationKind, IntegrationProvider, IntegrationStatus
|
||||
|
||||
from chatballs.integrations.serializers import integration_payload
|
||||
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration
|
||||
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
|
||||
|
||||
EMAIL_INPUT = {
|
||||
|
||||
"email": "Support@example.com",
|
||||
|
||||
"imapHost": "imap.yandex.ru",
|
||||
|
||||
"smtpHost": "smtp.yandex.ru",
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class EmailConfigTests(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
|
||||
self.context = system_tenant_context(Organization.objects.get(slug="demo"))
|
||||
|
||||
|
||||
|
||||
def _create(self, *, secret="app-password", config=None):
|
||||
|
||||
return create_integration(
|
||||
|
||||
context=self.context,
|
||||
|
||||
data=IntegrationInput(provider=IntegrationProvider.EMAIL, name="Почта", secret=secret, config=config or dict(EMAIL_INPUT)),
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_email_is_messenger_kind_with_normalized_config(self) -> None:
|
||||
|
||||
integration = self._create()
|
||||
|
||||
self.assertEqual(integration.kind, IntegrationKind.MESSENGER)
|
||||
|
||||
# camelCase → snake_case, адрес приводится к lowercase, порты/SSL по умолчанию.
|
||||
|
||||
self.assertEqual(
|
||||
|
||||
integration.config,
|
||||
|
||||
{
|
||||
|
||||
"email": "support@example.com",
|
||||
|
||||
"imap_host": "imap.yandex.ru",
|
||||
|
||||
"imap_port": 993,
|
||||
|
||||
"imap_ssl": True,
|
||||
|
||||
"smtp_host": "smtp.yandex.ru",
|
||||
|
||||
"smtp_port": 465,
|
||||
|
||||
"smtp_ssl": True,
|
||||
|
||||
},
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_secret_required_on_create(self) -> None:
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
|
||||
self._create(secret="")
|
||||
|
||||
|
||||
|
||||
def test_hosts_and_address_required(self) -> None:
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
|
||||
self._create(config={"email": "a@b.c", "imapHost": "imap.b.c"})
|
||||
|
||||
|
||||
|
||||
def test_notifications_purpose_rejected(self) -> None:
|
||||
|
||||
# Email не может быть сервисным ботом уведомлений (ADR-HUB-0035, границы).
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
|
||||
self._create(config={**EMAIL_INPUT, "purpose": "notifications"})
|
||||
|
||||
|
||||
|
||||
def test_serializer_exposes_email_config(self) -> None:
|
||||
|
||||
payload = integration_payload(self._create())
|
||||
|
||||
config = payload["config"]
|
||||
|
||||
self.assertEqual(config["email"], "support@example.com")
|
||||
|
||||
self.assertEqual(config["imapHost"], "imap.yandex.ru")
|
||||
|
||||
self.assertEqual(config["imapPort"], 993)
|
||||
|
||||
self.assertTrue(config["smtpSsl"])
|
||||
|
||||
self.assertTrue(payload["hasSecret"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class EmailCheckTests(TestCase):
|
||||
|
||||
"""check_email: успех только когда успешны ОБЕ стороны (IMAP и SMTP)."""
|
||||
|
||||
|
||||
|
||||
CONFIG = {
|
||||
|
||||
"email": "support@example.com",
|
||||
|
||||
"imap_host": "imap.test",
|
||||
|
||||
"imap_port": 993,
|
||||
|
||||
"imap_ssl": True,
|
||||
|
||||
"smtp_host": "smtp.test",
|
||||
|
||||
"smtp_port": 465,
|
||||
|
||||
"smtp_ssl": True,
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
def test_both_sides_ok(self) -> None:
|
||||
|
||||
with (
|
||||
|
||||
mock.patch.object(checks.imaplib, "IMAP4_SSL") as imap_cls,
|
||||
|
||||
mock.patch.object(checks.smtplib, "SMTP_SSL") as smtp_cls,
|
||||
|
||||
):
|
||||
|
||||
ok, detail, meta = checks.check_email(secret="pw", config=self.CONFIG)
|
||||
|
||||
self.assertTrue(ok)
|
||||
|
||||
self.assertEqual(detail, "Email: support@example.com")
|
||||
|
||||
imap_cls.return_value.login.assert_called_once_with("support@example.com", "pw")
|
||||
|
||||
smtp_cls.return_value.login.assert_called_once_with("support@example.com", "pw")
|
||||
|
||||
|
||||
|
||||
def test_imap_failure_reported(self) -> None:
|
||||
|
||||
with mock.patch.object(checks.imaplib, "IMAP4_SSL", side_effect=OSError("connection refused")):
|
||||
|
||||
ok, detail, _ = checks.check_email(secret="pw", config=self.CONFIG)
|
||||
|
||||
self.assertFalse(ok)
|
||||
|
||||
self.assertIn("IMAP:", detail)
|
||||
|
||||
|
||||
|
||||
def test_smtp_failure_reported(self) -> None:
|
||||
|
||||
with (
|
||||
|
||||
mock.patch.object(checks.imaplib, "IMAP4_SSL"),
|
||||
|
||||
mock.patch.object(checks.smtplib, "SMTP_SSL", side_effect=OSError("connection refused")),
|
||||
|
||||
):
|
||||
|
||||
ok, detail, _ = checks.check_email(secret="pw", config=self.CONFIG)
|
||||
|
||||
self.assertFalse(ok)
|
||||
|
||||
self.assertIn("SMTP:", detail)
|
||||
|
||||
|
||||
|
||||
def test_missing_secret_fails(self) -> None:
|
||||
|
||||
ok, detail, _ = checks.check_email(secret="", config=self.CONFIG)
|
||||
|
||||
self.assertFalse(ok)
|
||||
|
||||
self.assertIn("пароль", detail)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class EmailTestIntegrationDispatchTests(TestCase):
|
||||
|
||||
"""test_integration диспетчеризует EMAIL на check_email (полный config)."""
|
||||
|
||||
|
||||
|
||||
def setUp(self) -> None:
|
||||
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
|
||||
self.context = system_tenant_context(Organization.objects.get(slug="demo"))
|
||||
|
||||
|
||||
|
||||
def test_email_check_updates_status(self) -> None:
|
||||
|
||||
from chatballs.integrations.services import test_integration as run_integration_test
|
||||
|
||||
|
||||
|
||||
integration = create_integration(
|
||||
|
||||
context=self.context,
|
||||
|
||||
data=IntegrationInput(provider=IntegrationProvider.EMAIL, name="Почта", secret="pw", config=dict(EMAIL_INPUT)),
|
||||
|
||||
)
|
||||
|
||||
with mock.patch.object(checks, "check_email", return_value=(True, "Email: support@example.com", {})) as check:
|
||||
|
||||
checked = run_integration_test(context=self.context, integration=integration)
|
||||
|
||||
check.assert_called_once()
|
||||
|
||||
self.assertEqual(checked.status, IntegrationStatus.OK)
|
||||
|
||||
self.assertEqual(checked.last_error, "")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@ from chatballs.testing import TenantAPIClient as APIClient, tenant_context_for
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxEvent
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import HumanUser, Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.notifications.binding import deep_link, handle_notifier_inbound, issue_binding_code
|
||||
@@ -29,9 +29,9 @@ def _notifier(organization, provider=IntegrationProvider.TELEGRAM, username="ede
|
||||
|
||||
class NotifierTestBase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.owner = HumanUser.objects.get(email="owner@edevs.tech")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self.context = tenant_context_for(self.owner, self.organization)
|
||||
self.integration = _notifier(self.organization)
|
||||
|
||||
@@ -114,7 +114,7 @@ class DeliveryTests(NotifierTestBase):
|
||||
send.assert_not_called()
|
||||
|
||||
def test_user_audience_only_reaches_recipient(self) -> None:
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send:
|
||||
notify(
|
||||
context=self.context,
|
||||
@@ -127,7 +127,7 @@ class DeliveryTests(NotifierTestBase):
|
||||
send.assert_not_called() # у оператора нет привязки; owner не адресат
|
||||
|
||||
def test_operator_sees_operator_audience_but_not_owner_audience(self) -> None:
|
||||
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
|
||||
operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
operators_notification = notify(
|
||||
context=self.context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
@@ -156,7 +156,7 @@ class PollerSelectionTests(NotifierTestBase):
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations import poller
|
||||
|
||||
channel = Channel.objects.create(organization=self.organization, code="foxray-sales", name="FoxRay — продажи")
|
||||
channel = Channel.objects.create(organization=self.organization, code="app-sales", name="FoxRay — продажи")
|
||||
client_bot = Integration.objects.create(
|
||||
organization=self.organization, kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM, name="client-bot", secret="token", channel=channel,
|
||||
@@ -215,7 +215,7 @@ class BindingApiTests(NotifierTestBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def test_list_issue_and_unbind(self) -> None:
|
||||
listed = self.client.get("/api/v1/notifications/messenger-bindings/")
|
||||
|
||||
@@ -17,7 +17,7 @@ _IDEMPOTENCY_HEADER = "Idempotency-Key"
|
||||
|
||||
|
||||
class OrganizationProvisionView(APIView):
|
||||
"""POST platform.custocrm.ru/api/v1/organizations (SPEC-HUB-0021 §12).
|
||||
"""POST <platform-host>/api/v1/organizations (SPEC-HUB-0021 §12).
|
||||
|
||||
The first Platform API adapter: machine-to-machine provisioning via an
|
||||
opaque platform token. Does not reuse tenant HasCapability (no membership
|
||||
|
||||
@@ -3,17 +3,17 @@ import json
|
||||
from django.test import TestCase
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.products.models import Product, ProductStatus
|
||||
|
||||
|
||||
class ProductApiTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def test_create_product_is_active(self) -> None:
|
||||
response = self.client.post(
|
||||
@@ -33,13 +33,13 @@ class ProductApiTests(TestCase):
|
||||
self.assertEqual(payload["status"], ProductStatus.ACTIVE)
|
||||
|
||||
def test_update_does_not_change_product_code(self) -> None:
|
||||
product = Product.objects.get(code="firepage")
|
||||
product = Product.objects.get(code="site")
|
||||
response = self.client.patch(
|
||||
f"/api/v1/company/products/{product.id}/update/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"code": "changed-code",
|
||||
"name": "FirePage Updated",
|
||||
"name": "Сайт Updated",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
@@ -47,8 +47,8 @@ class ProductApiTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
product.refresh_from_db()
|
||||
self.assertEqual(product.code, "firepage")
|
||||
self.assertEqual(product.name, "FirePage Updated")
|
||||
self.assertEqual(product.code, "site")
|
||||
self.assertEqual(product.name, "Сайт Updated")
|
||||
|
||||
def test_product_detail_is_limited_to_user_organization(self) -> None:
|
||||
other = Organization.objects.create(name="Other", slug="other")
|
||||
@@ -61,7 +61,7 @@ class ProductApiTests(TestCase):
|
||||
def test_product_payload_exposes_assigned_channels(self) -> None:
|
||||
from chatballs.channels.models import Channel
|
||||
|
||||
product = Product.objects.get(code="firepage")
|
||||
product = Product.objects.get(code="site")
|
||||
Channel.objects.create(organization=self.organization, code="site", name="Сайт", product=product)
|
||||
|
||||
response = self.client.get("/api/v1/company/products/")
|
||||
|
||||
@@ -20,12 +20,12 @@ def _b64url_encode(raw: bytes) -> str:
|
||||
def make_support_token(
|
||||
*,
|
||||
secret: str,
|
||||
iss: str = "foxray",
|
||||
contract: str = "foxray.support.v1",
|
||||
iss: str = "app",
|
||||
contract: str = "app.support.v1",
|
||||
data: dict[str, Any] | None = None,
|
||||
exp_delta: int = 600,
|
||||
jti: str = "test_jti_001",
|
||||
aud: str = "edevshub.support",
|
||||
aud: str = "chatballs.support",
|
||||
) -> str:
|
||||
"""Подписанный HS256 Product Support Token для тестов."""
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.test import TestCase
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.products.models import Product
|
||||
from chatballs.support.models import ContractStatus, ProductSupportContract
|
||||
@@ -26,7 +26,7 @@ def _setup_support_channel(
|
||||
contract = ProductSupportContract.objects.create(
|
||||
organization=organization,
|
||||
product=product,
|
||||
code="foxray.support.v1",
|
||||
code="app.support.v1",
|
||||
version=1,
|
||||
status=ContractStatus.ACTIVE,
|
||||
schema_json={
|
||||
@@ -56,7 +56,7 @@ def _setup_support_channel(
|
||||
)
|
||||
channel = Channel.objects.create(
|
||||
organization=organization,
|
||||
code="foxray-support",
|
||||
code="app-support",
|
||||
name="FoxRay — поддержка",
|
||||
product=product,
|
||||
requires_authenticated_product_identity=True,
|
||||
@@ -71,9 +71,9 @@ def _setup_support_channel(
|
||||
|
||||
class SupportWidgetMessagesTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.product = Product.objects.get(organization=self.organization, code="foxray")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.product = Product.objects.get(organization=self.organization, code="app")
|
||||
self.channel, self.contract = _setup_support_channel(
|
||||
self.organization, self.product
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.audit import AuditResult
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import AuditEvent, Organization
|
||||
from chatballs.products.models import Product
|
||||
from chatballs.support.models import (
|
||||
@@ -22,11 +22,11 @@ from chatballs.webchat.testing import create_web_widget
|
||||
SECRET = "test-support-secret-very-long-32bytes!!"
|
||||
|
||||
|
||||
def _foxray_contract(organization, product) -> ProductSupportContract:
|
||||
def _app_contract(organization, product) -> ProductSupportContract:
|
||||
contract = ProductSupportContract.objects.create(
|
||||
organization=organization,
|
||||
product=product,
|
||||
code="foxray.support.v1",
|
||||
code="app.support.v1",
|
||||
version=1,
|
||||
status=ContractStatus.ACTIVE,
|
||||
schema_json={
|
||||
@@ -78,15 +78,15 @@ def _foxray_contract(organization, product) -> ProductSupportContract:
|
||||
|
||||
class SupportSessionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.product = Product.objects.get(organization=self.organization, code="foxray")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.product = Product.objects.get(organization=self.organization, code="app")
|
||||
self.product.support_token_secret = SECRET
|
||||
self.product.save(update_fields=["support_token_secret"])
|
||||
self.contract = _foxray_contract(self.organization, self.product)
|
||||
self.contract = _app_contract(self.organization, self.product)
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
code="foxray-support",
|
||||
code="app-support",
|
||||
name="FoxRay — поддержка",
|
||||
product=self.product,
|
||||
requires_authenticated_product_identity=True,
|
||||
@@ -114,7 +114,7 @@ class SupportSessionTests(TestCase):
|
||||
self.assertIn("conversation", body)
|
||||
self.assertIn("snapshot", body)
|
||||
snapshot = SupportIdentitySnapshot.objects.get(subject_key="u_456")
|
||||
self.assertEqual(snapshot.contract_code, "foxray.support.v1")
|
||||
self.assertEqual(snapshot.contract_code, "app.support.v1")
|
||||
self.assertEqual(snapshot.display_name, "Иван Петров")
|
||||
self.assertEqual(snapshot.display_email, "doctor@example.com")
|
||||
self.assertEqual(snapshot.account_key, "c_123")
|
||||
@@ -173,7 +173,7 @@ class SupportSessionTests(TestCase):
|
||||
# Канал без support-политики (анонимные сессии разрешены) не может
|
||||
# принимать support-токен.
|
||||
sales_channel = Channel.objects.create(
|
||||
organization=self.organization, code="foxray-sales-x",
|
||||
organization=self.organization, code="app-sales-x",
|
||||
name="FoxRay sales", product=self.product,
|
||||
)
|
||||
invalid_widget = create_web_widget(
|
||||
@@ -187,8 +187,8 @@ class SupportSessionTests(TestCase):
|
||||
self._assert_denied_audit("CHANNEL_NOT_SUPPORT")
|
||||
|
||||
def test_channel_product_mismatch_denied(self) -> None:
|
||||
# iss=firepage, но канал привязан к foxray.
|
||||
token = make_support_token(secret=SECRET, iss="firepage", data=FOXRAY_DATA)
|
||||
# iss=site, но канал привязан к app.
|
||||
token = make_support_token(secret=SECRET, iss="site", data=FOXRAY_DATA)
|
||||
response = self._start(token)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self._assert_denied_audit("CHANNEL_PRODUCT_MISMATCH")
|
||||
@@ -236,17 +236,17 @@ class SupportSessionTests(TestCase):
|
||||
|
||||
class SupportContractApiTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.product = Product.objects.get(organization=self.organization, code="foxray")
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.product = Product.objects.get(organization=self.organization, code="app")
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@edevs.tech", password="temporary-password")
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def test_owner_registers_contract(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/v1/support/contracts/",
|
||||
data=json.dumps({
|
||||
"code": "foxray.support.v2",
|
||||
"code": "app.support.v2",
|
||||
"productId": self.product.id,
|
||||
"status": "DRAFT",
|
||||
"schemaJson": {},
|
||||
@@ -255,15 +255,15 @@ class SupportContractApiTests(TestCase):
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
self.assertEqual(response.json()["contract"]["code"], "foxray.support.v2")
|
||||
self.assertEqual(response.json()["contract"]["code"], "app.support.v2")
|
||||
self.assertEqual(response.json()["contract"]["version"], 2)
|
||||
|
||||
def test_operator_cannot_register_contract(self) -> None:
|
||||
# Оператор (роль EMPLOYEE) не имеет прав на управление контрактами.
|
||||
self.client.login(username="a.kotova@edevs.tech", password="Operator-Local-2026")
|
||||
self.client.login(username="staff.member@example.org", password="Operator-Local-2026")
|
||||
response = self.client.post(
|
||||
"/api/v1/support/contracts/",
|
||||
data=json.dumps({"code": "foxray.support.v3", "productId": self.product.id}),
|
||||
data=json.dumps({"code": "app.support.v3", "productId": self.product.id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -271,7 +271,7 @@ class SupportContractApiTests(TestCase):
|
||||
def test_contract_code_must_match_product(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/v1/support/contracts/",
|
||||
data=json.dumps({"code": "firepage.support.v1", "productId": self.product.id}),
|
||||
data=json.dumps({"code": "site.support.v1", "productId": self.product.id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@@ -21,7 +21,7 @@ from typing import Any
|
||||
|
||||
from chatballs.support import errors
|
||||
|
||||
SUPPORT_AUDIENCE = "edevshub.support"
|
||||
SUPPORT_AUDIENCE = "chatballs.support"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.products.models import Product
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
@@ -14,20 +14,20 @@ class SupportPortalTestCase(TestCase):
|
||||
client = None
|
||||
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_edevs_owner(
|
||||
email="portal-owner@edevs.tech",
|
||||
result = bootstrap_owner(
|
||||
email="portal-owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.organization = result.organization
|
||||
self.owner = result.owner
|
||||
self.product = Product.objects.get(
|
||||
organization=self.organization,
|
||||
code="foxray",
|
||||
code="app",
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
code="foxray-help",
|
||||
name="Foxray support",
|
||||
code="app-help",
|
||||
name="Приложение support",
|
||||
product=self.product,
|
||||
requires_authenticated_product_identity=True,
|
||||
allow_anonymous_sessions=False,
|
||||
@@ -35,9 +35,9 @@ class SupportPortalTestCase(TestCase):
|
||||
)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(self.owner)
|
||||
def create_portal(self, slug: str = "foxray-help"):
|
||||
def create_portal(self, slug: str = "app-help"):
|
||||
return self.client.post(
|
||||
"/api/v1/support/portals/",
|
||||
{"slug": slug, "name": "Foxray Help", "defaultLocale": "ru"},
|
||||
{"slug": slug, "name": "Приложение Help", "defaultLocale": "ru"},
|
||||
format="json",
|
||||
)
|
||||
@@ -20,7 +20,7 @@ class SupportPortalManagementTests(SupportPortalTestCase):
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
widget_channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
code="foxray-portal-chat",
|
||||
code="app-portal-chat",
|
||||
name="FoxRay — чат портала",
|
||||
product=self.product,
|
||||
requires_authenticated_product_identity=False,
|
||||
@@ -51,7 +51,7 @@ class SupportPortalManagementTests(SupportPortalTestCase):
|
||||
|
||||
config = self.client.get(
|
||||
f"/api/v1/webchat/config/?widgetKey={widget.public_key}",
|
||||
HTTP_ORIGIN="http://foxray-help.localhost",
|
||||
HTTP_ORIGIN="http://app-help.localhost",
|
||||
)
|
||||
self.assertEqual(config.status_code, 200, config.content)
|
||||
self.assertTrue(config.json()["available"])
|
||||
@@ -78,10 +78,10 @@ class SupportPortalManagementTests(SupportPortalTestCase):
|
||||
self.assertEqual(first.status_code, 201, first.content)
|
||||
self.assertEqual(
|
||||
first.json()["portal"]["hostedDomain"],
|
||||
f"foxray-help.{settings.CHATBALLS_HELP_BASE_DOMAIN}",
|
||||
f"app-help.{settings.CHATBALLS_HELP_BASE_DOMAIN}",
|
||||
)
|
||||
self.assertTrue(first.json()["portal"]["publicUrl"].endswith(
|
||||
f"foxray-help.{settings.CHATBALLS_HELP_BASE_DOMAIN}"
|
||||
f"app-help.{settings.CHATBALLS_HELP_BASE_DOMAIN}"
|
||||
))
|
||||
|
||||
listed = self.client.get("/api/v1/support/portals/")
|
||||
|
||||
@@ -111,7 +111,7 @@ class SupportPortalContentManagementTests(SupportPortalTestCase):
|
||||
|
||||
def test_support_operator_uses_portal_scoped_widget_options(self) -> None:
|
||||
portal_id = self.create_portal().json()["portal"]["id"]
|
||||
widget = create_web_widget(self.channel, name="Foxray support widget")
|
||||
widget = create_web_widget(self.channel, name="Приложение support widget")
|
||||
response = self.client.get(
|
||||
f"/api/v1/support/portals/{portal_id}/support-channels/"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.support_portals.models import PortalArticleFeedback, SupportPortal
|
||||
from chatballs.testing import TenantAPIClient
|
||||
@@ -10,8 +10,8 @@ from chatballs.webchat.testing import create_web_widget
|
||||
|
||||
class PublicSupportPortalTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_edevs_owner(
|
||||
email="public-portal-owner@edevs.tech",
|
||||
result = bootstrap_owner(
|
||||
email="public-portal-owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.client = TenantAPIClient()
|
||||
@@ -70,8 +70,8 @@ class PublicSupportPortalTests(TestCase):
|
||||
|
||||
def test_published_portal_exposes_search_article_and_feedback(self) -> None:
|
||||
self.client.force_authenticate(
|
||||
bootstrap_edevs_owner(
|
||||
email="public-portal-owner@edevs.tech",
|
||||
bootstrap_owner(
|
||||
email="public-portal-owner@example.com",
|
||||
password="temporary-password",
|
||||
).owner
|
||||
)
|
||||
@@ -121,11 +121,11 @@ class PublicSupportPortalTests(TestCase):
|
||||
self.assertEqual(feedback.status_code, 201, feedback.content)
|
||||
self.assertEqual(PortalArticleFeedback.objects.filter(helpful=True).count(), 1)
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=["testserver", "help.foxray.example"])
|
||||
@override_settings(ALLOWED_HOSTS=["testserver", "help.app.example"])
|
||||
def test_verified_custom_domain_resolves_the_same_portal(self) -> None:
|
||||
self.client.force_authenticate(
|
||||
bootstrap_edevs_owner(
|
||||
email="public-portal-owner@edevs.tech",
|
||||
bootstrap_owner(
|
||||
email="public-portal-owner@example.com",
|
||||
password="temporary-password",
|
||||
).owner
|
||||
)
|
||||
@@ -135,14 +135,14 @@ class PublicSupportPortalTests(TestCase):
|
||||
format="json",
|
||||
)
|
||||
SupportPortal.objects.filter(id=self.portal_id).update(
|
||||
custom_domain="help.foxray.example",
|
||||
custom_domain="help.app.example",
|
||||
custom_domain_verified_at=timezone.now(),
|
||||
)
|
||||
self.client.logout()
|
||||
|
||||
response = self.client.get(
|
||||
"/api/v1/help/",
|
||||
HTTP_HOST="help.foxray.example",
|
||||
HTTP_HOST="help.app.example",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
@@ -150,7 +150,7 @@ class PublicSupportPortalTests(TestCase):
|
||||
|
||||
@override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
|
||||
def test_gateway_authorizes_only_published_portal_domains(self) -> None:
|
||||
custom_domain = "help.foxray.example"
|
||||
custom_domain = "help.app.example"
|
||||
SupportPortal.objects.filter(id=self.portal_id).update(
|
||||
status="PUBLISHED",
|
||||
custom_domain=custom_domain,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Публичный JS-лоадер виджета (SPEC-HUB-0003 §3, SPEC-HUB-0010 §7.1). Подключается
|
||||
# одним тегом:
|
||||
# <script src="https://hub.edevs.tech/chat-widget.js"
|
||||
# <script src="https://<ваш-домен>/chat-widget.js"
|
||||
# data-widget-key="wgt_public_key" async></script>
|
||||
# Authenticated host регистрирует async token provider через ChatballsChat.init;
|
||||
# Product Support Token никогда не попадает в URL или data-*.
|
||||
@@ -8,7 +8,7 @@
|
||||
#
|
||||
# TODO (SPEC-HUB-0010 §7.3, security): для production настроить CSP
|
||||
# `frame-ancestors` для /chat/ (раздаётся vite/nginx, не Django — настраивается в
|
||||
# infra/deploy), разрешив домены продуктов Edevs (foxray.pro, firepage.ru и т.д.).
|
||||
# infra/deploy), разрешив домены сайтов, где стоит виджет.
|
||||
# Только origin недостаточен — support-виджет дополнительно проверяется signed
|
||||
# Product Support Token. Домены — у владельца.
|
||||
|
||||
@@ -40,13 +40,13 @@ LOADER_JS = r"""
|
||||
};
|
||||
|
||||
var style = document.createElement("style");
|
||||
style.textContent = "@keyframes edevs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes edevs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.edevs-chat-message-bump{animation:edevs-chat-message-bump .42s ease-out}.edevs-chat-call-shake{animation:edevs-chat-call-shake 3.2s ease-in-out infinite}.edevs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.edevs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}";
|
||||
style.textContent = "@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}";
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
|
||||
// Launcher — «пилюля» с иконкой (Tabler brand-hipchat) и подписью. Иконка
|
||||
// вшита как inline SVG: на сайте продукта шрифта Tabler нет.
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "edevs-chat-launcher";
|
||||
btn.className = "chatballs-chat-launcher";
|
||||
btn.type = "button";
|
||||
btn.setAttribute("aria-label", "Открыть чат");
|
||||
btn.style.cssText = "position:fixed;right:24px;bottom:24px;height:52px;padding:0 20px;border:none;border-radius:999px;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483000;display:flex;align-items:center;gap:9px;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;font-size:15px;font-weight:600;line-height:1;letter-spacing:0;transition:transform .18s ease,box-shadow .18s ease;";
|
||||
@@ -73,15 +73,15 @@ LOADER_JS = r"""
|
||||
}
|
||||
|
||||
function bumpLauncher() {
|
||||
btn.classList.remove("edevs-chat-message-bump");
|
||||
btn.classList.remove("chatballs-chat-message-bump");
|
||||
void btn.offsetWidth;
|
||||
btn.classList.add("edevs-chat-message-bump");
|
||||
window.setTimeout(function () { btn.classList.remove("edevs-chat-message-bump"); }, 450);
|
||||
btn.classList.add("chatballs-chat-message-bump");
|
||||
window.setTimeout(function () { btn.classList.remove("chatballs-chat-message-bump"); }, 450);
|
||||
}
|
||||
|
||||
function setCallActive(active) {
|
||||
callActive = active;
|
||||
btn.classList.toggle("edevs-chat-call-shake", active);
|
||||
btn.classList.toggle("chatballs-chat-call-shake", active);
|
||||
if (active) play(ringtone);
|
||||
else {
|
||||
ringtone.pause();
|
||||
@@ -105,19 +105,19 @@ LOADER_JS = r"""
|
||||
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
|
||||
var d = e.data || {};
|
||||
if (d.instanceId && d.instanceId !== instanceId) return;
|
||||
if (d.type === "edevs-chat-close") setOpen(false);
|
||||
if (d.type === "edevs-chat-unread") {
|
||||
if (d.type === "chatballs-chat-close") setOpen(false);
|
||||
if (d.type === "chatballs-chat-unread") {
|
||||
unread = Boolean(d.unread);
|
||||
updateDot();
|
||||
}
|
||||
if (d.type === "edevs-chat-activity" && d.kind === "message") {
|
||||
if (d.type === "chatballs-chat-activity" && d.kind === "message") {
|
||||
unread = !open;
|
||||
updateDot();
|
||||
if (!open && !callActive) bumpLauncher();
|
||||
notification.currentTime = 0;
|
||||
play(notification);
|
||||
}
|
||||
if (d.type === "edevs-chat-activity" && d.kind === "call") setCallActive(Boolean(d.active));
|
||||
if (d.type === "chatballs-chat-activity" && d.kind === "call") setCallActive(Boolean(d.active));
|
||||
if (d.type === "chatballs-chat-token-request" && tokenProvider) {
|
||||
Promise.resolve().then(tokenProvider).then(function (token) {
|
||||
frame.contentWindow.postMessage({
|
||||
@@ -151,7 +151,7 @@ LOADER_JS = r"""
|
||||
if (widgetKey && api._providers[widgetKey]) enableAuthenticated(api._providers[widgetKey]);
|
||||
|
||||
function notifyOpened() {
|
||||
try { frame.contentWindow.postMessage({ type: "edevs-chat-opened" }, origin); } catch (_) {}
|
||||
try { frame.contentWindow.postMessage({ type: "chatballs-chat-opened" }, origin); } catch (_) {}
|
||||
}
|
||||
|
||||
function setOpen(next) {
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_edevs_owner
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.webchat.models import WebSession
|
||||
from chatballs.webchat.testing import create_web_widget
|
||||
@@ -12,11 +12,11 @@ from chatballs.webchat.testing import create_web_widget
|
||||
|
||||
class PublicWebChatWidgetTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_edevs_owner(
|
||||
email="webchat-owner@edevs.tech",
|
||||
bootstrap_owner(
|
||||
email="webchat-owner@example.com",
|
||||
password="temporary-password",
|
||||
)
|
||||
self.organization = Organization.objects.get(slug="edevs")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization,
|
||||
code="website-chat",
|
||||
|
||||
@@ -28,7 +28,7 @@ if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
|
||||
CHATBALLS_DELIVERY_MODE = _delivery_mode
|
||||
ALLOWED_HOSTS = env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
|
||||
if TESTING:
|
||||
ALLOWED_HOSTS.extend(["testserver", ".help.custocrm.ru", ".localhost"])
|
||||
ALLOWED_HOSTS.extend(["testserver", ".localhost"])
|
||||
CSRF_TRUSTED_ORIGINS = env_list("CHATBALLS_CSRF_TRUSTED_ORIGINS", [])
|
||||
|
||||
# Запрещаем запуск в production с дефолтным/пустым ключом подписи.
|
||||
@@ -139,14 +139,14 @@ TIME_ZONE = "Europe/Moscow"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Email (env-driven; console backend is the safe local default until SMTP Edevs is wired in E02).
|
||||
# Email (env-driven; console backend — безопасный локальный дефолт, SMTP задаётся установщиком).
|
||||
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
|
||||
EMAIL_HOST = os.environ.get("EMAIL_HOST", "")
|
||||
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
|
||||
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "true").lower() == "true"
|
||||
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Chatballs <no-reply@edevs.tech>")
|
||||
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Chatballs <no-reply@localhost>")
|
||||
|
||||
# Base URL of the internal UI, used to build links inside transactional emails.
|
||||
INTERNAL_UI_BASE_URL = os.environ.get("INTERNAL_UI_BASE_URL", "http://localhost:5173")
|
||||
@@ -214,9 +214,11 @@ CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
|
||||
CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# Публичные порталы поддержки размещаются на отдельных хостах.
|
||||
# Базовый домен порталов помощи задаёт установщик (CHATBALLS_HELP_BASE_DOMAIN);
|
||||
# по умолчанию — localhost, никаких зашитых доменов.
|
||||
CHATBALLS_HELP_BASE_DOMAIN = os.environ.get(
|
||||
"CHATBALLS_HELP_BASE_DOMAIN",
|
||||
"help.custocrm.ru",
|
||||
"localhost",
|
||||
).strip().lower().rstrip(".")
|
||||
CHATBALLS_HELP_PUBLIC_SCHEME = os.environ.get("CHATBALLS_HELP_PUBLIC_SCHEME", "https").strip().lower()
|
||||
CHATBALLS_HELP_PUBLIC_PORT = os.environ.get("CHATBALLS_HELP_PUBLIC_PORT", "").strip()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@edevs/internal-ui",
|
||||
"name": "@chatballs/internal-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
@@ -11,9 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@edevs/contracts": "0.1.0",
|
||||
"@edevs/shared": "0.1.0",
|
||||
"@edevs/ui": "0.1.0",
|
||||
"@chatballs/contracts": "0.1.0",
|
||||
"@chatballs/shared": "0.1.0",
|
||||
"@chatballs/ui": "0.1.0",
|
||||
"antd": "^5.26.0",
|
||||
"js-yaml": "^5.2.1",
|
||||
"lucide": "^1.41.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ConfigProvider } from "antd";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { buildHubTheme } from "@edevs/ui";
|
||||
import { buildTheme } from "@chatballs/ui";
|
||||
|
||||
import { applyAppearance, DEFAULT_ACCENT, resolvedDark } from "./shared/appearance";
|
||||
|
||||
@@ -26,7 +26,7 @@ export function App() {
|
||||
applyAppearance(appearanceTheme, appearanceAccent);
|
||||
}, [appearanceTheme, appearanceAccent]);
|
||||
const antdTheme = useMemo(
|
||||
() => buildHubTheme(resolvedDark(appearanceTheme), appearanceAccent),
|
||||
() => buildTheme(resolvedDark(appearanceTheme), appearanceAccent),
|
||||
[appearanceTheme, appearanceAccent],
|
||||
);
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Видео не запрашивается. Флоу: incoming/ringing → connecting → active → терминал,
|
||||
// без pre-call (нет этапа проверки камеры). Параллель VideoCallOverlay, но под аудио.
|
||||
|
||||
import { AudioCallView, type AudioCallMode, buildAudioStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui";
|
||||
import { AudioCallView, type AudioCallMode, buildAudioStatus, useCallRtcSession, useLoopingAudio } from "@chatballs/ui";
|
||||
import { Modal } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// из CallOverlay при добавлении аудиозвонка, чтобы CallOverlay остался тонким
|
||||
// диспетчером по call.kind (NO GOD / separation of concerns).
|
||||
|
||||
import { CallView, type CallViewMode, type CallViewStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui";
|
||||
import { CallView, type CallViewMode, type CallViewStatus, useCallRtcSession, useLoopingAudio } from "@chatballs/ui";
|
||||
import { Modal } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ describe("splitQuotedEmail", () => {
|
||||
});
|
||||
|
||||
it("separates an inline localized reply header", () => {
|
||||
const text = "Держись\nСр, 22 июля 2026 г. в 21:10, Foxray <foxray@edevs.tech>: > Старое письмо";
|
||||
const text = "Держись\nСр, 22 июля 2026 г. в 21:10, Foxray <foxray@example.com>: > Старое письмо";
|
||||
expect(splitQuotedEmail(text)).toEqual({
|
||||
latest: "Держись",
|
||||
quoted: "Ср, 22 июля 2026 г. в 21:10, Foxray <foxray@edevs.tech>: > Старое письмо",
|
||||
quoted: "Ср, 22 июля 2026 г. в 21:10, Foxray <foxray@example.com>: > Старое письмо",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useAudioCue } from "@edevs/ui";
|
||||
import { useAudioCue } from "@chatballs/ui";
|
||||
|
||||
import type { ApiConversation } from "./model";
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ describe("webWidgetSnippet", () => {
|
||||
vi.stubGlobal("window", { location: { origin: "https://hub.example.com" } });
|
||||
const { webWidgetSnippet } = await import("./model");
|
||||
|
||||
expect(webWidgetSnippet("wgt_edeves")).toBe(
|
||||
`<script src="https://hub.example.com/chat-widget.js" data-widget-key="wgt_edeves" async></script>`,
|
||||
expect(webWidgetSnippet("wgt_demo")).toBe(
|
||||
`<script src="https://hub.example.com/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -31,8 +31,8 @@ describe("webWidgetSnippet", () => {
|
||||
vi.stubGlobal("window", { location: { origin: "https://hub.example.com:8443" } });
|
||||
const { webWidgetSnippet } = await import("./model");
|
||||
|
||||
expect(webWidgetSnippet("wgt_edeves")).toBe(
|
||||
`<script src="https://hub.example.com:8443/chat-widget.js" data-widget-key="wgt_edeves" async></script>`,
|
||||
expect(webWidgetSnippet("wgt_demo")).toBe(
|
||||
`<script src="https://hub.example.com:8443/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -41,18 +41,18 @@ describe("parseAllowedOrigins", () => {
|
||||
it("splits on commas, semicolons and newlines and trims each entry", async () => {
|
||||
const { parseAllowedOrigins } = await import("./model");
|
||||
|
||||
expect(parseAllowedOrigins(` edevs.tech ,
|
||||
*.edevs.tech; foxray.pro `)).toEqual([
|
||||
"edevs.tech",
|
||||
"*.edevs.tech",
|
||||
"foxray.pro",
|
||||
expect(parseAllowedOrigins(` example.com ,
|
||||
*.example.com; shop.example `)).toEqual([
|
||||
"example.com",
|
||||
"*.example.com",
|
||||
"shop.example",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops empty entries, trailing slashes and case-insensitive duplicates", async () => {
|
||||
const { parseAllowedOrigins } = await import("./model");
|
||||
|
||||
expect(parseAllowedOrigins("edevs.tech/, ,, EDEVS.TECH, edevs.tech")).toEqual(["edevs.tech"]);
|
||||
expect(parseAllowedOrigins("example.com/, ,, EXAMPLE.COM, example.com")).toEqual(["example.com"]);
|
||||
});
|
||||
|
||||
it("returns an empty list for blank input so the form can require a domain", async () => {
|
||||
@@ -68,14 +68,14 @@ describe("invalidAllowedOrigin", () => {
|
||||
const { invalidAllowedOrigin } = await import("./model");
|
||||
|
||||
expect(
|
||||
invalidAllowedOrigin(["edevs.tech", "*.edevs.tech", "https://app.custocrm.ru", "localhost:5173"]),
|
||||
invalidAllowedOrigin(["example.com", "*.example.com", "https://app.example.com", "localhost:5173"]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports the first entry that is not a domain", async () => {
|
||||
const { invalidAllowedOrigin } = await import("./model");
|
||||
|
||||
expect(invalidAllowedOrigin(["edevs.tech", "https://edevs.tech/chat"])).toBe("https://edevs.tech/chat");
|
||||
expect(invalidAllowedOrigin(["example.com", "https://example.com/chat"])).toBe("https://example.com/chat");
|
||||
expect(invalidAllowedOrigin(["не домен"])).toBe("не домен");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SalesClientsPage({ openClient }: { openClient: (id: number) => v
|
||||
<EmptyState title="Не удалось загрузить контакты" />
|
||||
) : (
|
||||
<>
|
||||
<SalesClientsFilters clients={clients} />
|
||||
<SalesClientsFilters clients={clients} salesClients={data} />
|
||||
<SalesClientsTable clients={clients} openClient={openClient} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { avatarColor, channelMap, initialsOf, productMap, relativeTime, type ClientChannelCode, type ClientProductCode } from "../clients/model";
|
||||
import { avatarColor, channelMap, initialsOf, productStyle, relativeTime, type ClientChannelCode, type ClientProductRef } from "../clients/model";
|
||||
|
||||
export type ClientDetailTab = "overview" | "dialogs" | "ids" | "consent" | "audit";
|
||||
|
||||
@@ -20,7 +20,7 @@ export type ApiClientDetail = {
|
||||
email: string;
|
||||
phone: string;
|
||||
channels: ClientChannelCode[];
|
||||
products: ClientProductCode[];
|
||||
products: ClientProductRef[];
|
||||
openDialogs: number;
|
||||
totalDialogs: number;
|
||||
firstContactAt: string;
|
||||
@@ -67,7 +67,7 @@ export function toClientDetailVm(api: ApiClientDetail): ClientDetailVm {
|
||||
email: api.email || (isGuest ? "без контакта" : "—"),
|
||||
phone: api.phone || "—",
|
||||
channels: api.channels.map((code) => ({ label: channelMap[code].label, color: channelMap[code].color, bg: channelMap[code].bg })),
|
||||
products: api.products.map((code) => ({ name: productMap[code].name, color: productMap[code].color, bg: productMap[code].bg })),
|
||||
products: api.products.map((product) => ({ name: product.name, ...productStyle(product.code) })),
|
||||
summary: [
|
||||
{ label: "Диалоги", value: String(api.totalDialogs) },
|
||||
{ label: "Открытые", value: String(api.openDialogs) },
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Icon } from "../../../shared/icons";
|
||||
import { SearchInput } from "../../../shared/ui-controls";
|
||||
import { channelOptions, productOptions } from "./model";
|
||||
import { channelOptions, productOptionsOf, type SalesClient } from "./model";
|
||||
import type { SalesClientsState } from "./useSalesClients";
|
||||
|
||||
export function SalesClientsFilters({ clients }: { clients: SalesClientsState }) {
|
||||
export function SalesClientsFilters({ clients, salesClients }: { clients: SalesClientsState; salesClients: SalesClient[] }) {
|
||||
const productOptions = productOptionsOf(salesClients);
|
||||
return (
|
||||
<div className="sales-clients-filterbar">
|
||||
<SearchInput className="sales-clients-search" value={clients.query} onChange={clients.setQuery} placeholder="Поиск по имени, email, телефону или логину…" />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type ClientProductCode = "FP" | "FX";
|
||||
// Код продукта организации (любой) — справочник строится из данных, а не зашит.
|
||||
export type ClientProductCode = string;
|
||||
export type ClientProductRef = { code: ClientProductCode; name: string };
|
||||
export type ClientChannelCode = "EMAIL" | "MAX" | "TG" | "WEB";
|
||||
export type ClientSortKey = "last" | "open";
|
||||
export type ClientDropdown = "products" | "channels";
|
||||
@@ -14,7 +16,7 @@ export type SalesClient = {
|
||||
username: string;
|
||||
anon?: boolean;
|
||||
channels: ClientChannelCode[];
|
||||
products: ClientProductCode[];
|
||||
products: ClientProductRef[];
|
||||
last: number;
|
||||
lastLabel: string;
|
||||
mode: "wait" | "ai" | "operator" | "closed";
|
||||
@@ -35,10 +37,17 @@ export const channelMap = {
|
||||
WEB: { label: "Web", full: "Web Chat", color: "#0f9b8e", bg: "#e8f7f4" },
|
||||
} satisfies Record<ClientChannelCode, { label: string; full: string; color: string; bg: string }>;
|
||||
|
||||
export const productMap = {
|
||||
FP: { name: "FirePage", color: "#0958d9", bg: "#e6f4ff" },
|
||||
FX: { name: "Foxray", color: "#722ed1", bg: "#f9f0ff" },
|
||||
} satisfies Record<ClientProductCode, { name: string; color: string; bg: string }>;
|
||||
// Цвет продукта — детерминированно по коду (одна палитра с аватарами).
|
||||
export function productStyle(code: string): { color: string; bg: string } {
|
||||
const color = avatarColor(`product:${code}`);
|
||||
return { color, bg: `color-mix(in srgb, ${color} 12%, var(--surface-card))` };
|
||||
}
|
||||
|
||||
export function productOptionsOf(clients: SalesClient[]): Array<{ code: ClientProductCode; name: string }> {
|
||||
const seen = new Map<string, string>();
|
||||
for (const client of clients) for (const product of client.products) seen.set(product.code, product.name);
|
||||
return [...seen.entries()].map(([code, name]) => ({ code, name })).sort((a, b) => a.name.localeCompare(b.name, "ru"));
|
||||
}
|
||||
|
||||
export const channelOptions: Array<{ code: ClientChannelCode; name: string; color: string }> = [
|
||||
{ code: "EMAIL", name: "Email", color: channelMap.EMAIL.color },
|
||||
@@ -47,11 +56,6 @@ export const channelOptions: Array<{ code: ClientChannelCode; name: string; colo
|
||||
{ code: "WEB", name: "Web Chat", color: channelMap.WEB.color },
|
||||
];
|
||||
|
||||
export const productOptions: Array<{ code: ClientProductCode; name: string }> = [
|
||||
{ code: "FP", name: "FirePage" },
|
||||
{ code: "FX", name: "Foxray" },
|
||||
];
|
||||
|
||||
const statusDot = {
|
||||
wait: "#faad14",
|
||||
ai: "#722ed1",
|
||||
@@ -68,7 +72,7 @@ export type ApiClient = {
|
||||
email: string;
|
||||
username: string;
|
||||
channels: ClientChannelCode[];
|
||||
products: ClientProductCode[];
|
||||
products: ClientProductRef[];
|
||||
openDialogs: number;
|
||||
totalDialogs: number;
|
||||
lastActivityAt: string;
|
||||
@@ -123,7 +127,7 @@ export function toSalesClientRow(client: SalesClient): SalesClientRowVm {
|
||||
return {
|
||||
...client,
|
||||
channels: client.channels.map((channel) => channelMap[channel]),
|
||||
products: client.products.map((product) => productMap[product]),
|
||||
products: client.products.map((product) => ({ name: product.name, ...productStyle(product.code) })),
|
||||
lastDot: statusDot[client.mode],
|
||||
openColor: client.openDialogs > 0 ? "#d48806" : "#bfbfbf",
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useSalesClients(salesClients: SalesClient[]) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filtered = salesClients.filter((client) => {
|
||||
return (!normalizedQuery || client.name.toLowerCase().includes(normalizedQuery) || client.email.toLowerCase().includes(normalizedQuery) || client.phone.includes(normalizedQuery) || client.username.toLowerCase().includes(normalizedQuery))
|
||||
&& (productFilter.length === 0 || client.products.some((product) => productFilter.includes(product)))
|
||||
&& (productFilter.length === 0 || client.products.some((product) => productFilter.includes(product.code)))
|
||||
&& (channelFilter.length === 0 || client.channels.some((channel) => channelFilter.includes(channel)))
|
||||
&& (!openOnly || client.openDialogs > 0);
|
||||
});
|
||||
@@ -48,7 +48,7 @@ export function useSalesClients(salesClients: SalesClient[]) {
|
||||
|
||||
function toggleProduct(code: string) {
|
||||
setPage(1);
|
||||
setProductFilter((current) => current.includes(code as ClientProductCode) ? current.filter((item) => item !== code) : [...current, code as ClientProductCode]);
|
||||
setProductFilter((current) => current.includes(code) ? current.filter((item) => item !== code) : [...current, code]);
|
||||
}
|
||||
|
||||
function toggleChannel(code: string) {
|
||||
|
||||
@@ -71,7 +71,7 @@ export function PortalCreateDialog({
|
||||
<Modal open={open} title="Новый портал поддержки" footer={null} onCancel={onClose}>
|
||||
<div className="portal-dialog-form">
|
||||
<p>Портал — это публичный центр помощи с инструкциями и ответами для клиентов.</p>
|
||||
<FormField error={fieldErrors.name} label="Название портала" value={name} onChange={setName} placeholder="Например, Центр помощи Foxray" wide />
|
||||
<FormField error={fieldErrors.name} label="Название портала" value={name} onChange={setName} placeholder="Например, Центр помощи" wide />
|
||||
<label className={`portal-address-field${fieldErrors.slug ? " is-invalid" : ""}`}>
|
||||
<span>Адрес Chatballs</span>
|
||||
<div>
|
||||
@@ -79,7 +79,7 @@ export function PortalCreateDialog({
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())}
|
||||
placeholder="foxray"
|
||||
placeholder="help"
|
||||
autoComplete="off"
|
||||
maxLength={63}
|
||||
pattern="[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
|
||||
@@ -81,7 +81,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
|
||||
|
||||
const currentEmployee = route === "employeeDetail"
|
||||
? data.employees.find((employee) => employee.id === selectedEmployeeId) ?? null
|
||||
: data.employees.find((employee) => employee.email === "a.kotova@edevs.tech") ?? data.employees[0] ?? null;
|
||||
: data.employees[0] ?? null;
|
||||
function openEmployee(employee: Employee) {
|
||||
openEmployeeRoute(employee.id);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
export function initials(name: string, email: string): string {
|
||||
const source = name.trim() || email.split("@")[0] || "EH";
|
||||
const source = name.trim() || email.split("@")[0] || "CB";
|
||||
const parts = source.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return source.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
const PRODUCT_COLORS = ["#0958d9", "#722ed1", "#0f9b8e", "#d4860b", "#c4456b", "#52a838", "#4c6ef0", "#eb6f4b"];
|
||||
|
||||
// Цвет продукта — детерминированно по коду; никаких зашитых продуктов.
|
||||
export function productAccent(code: string): { bg: string; color: string } {
|
||||
if (code === "foxray") return { bg: "#f9f0ff", color: "#722ed1" };
|
||||
return { bg: "#e6f4ff", color: "#0958d9" };
|
||||
let hash = 0;
|
||||
for (let index = 0; index < code.length; index += 1) hash = (hash * 31 + code.charCodeAt(index)) >>> 0;
|
||||
const color = PRODUCT_COLORS[hash % PRODUCT_COLORS.length];
|
||||
return { bg: `color-mix(in srgb, ${color} 12%, var(--surface-card))`, color };
|
||||
}
|
||||
|
||||
export function formatDate(value: string): string {
|
||||
|
||||
@@ -10,7 +10,8 @@ describe("surfaceForHost", () => {
|
||||
|
||||
it("routes hosted help domains independently of their path", () => {
|
||||
expect(surfaceForHost("docs.localhost")).toBe("help");
|
||||
expect(surfaceForHost("docs.help.custocrm.ru")).toBe("help");
|
||||
expect(surfaceForHost("docs.help.localhost")).toBe("help");
|
||||
expect(surfaceForHost("docs.help.example.com")).toBe("loading");
|
||||
});
|
||||
|
||||
it("probes unknown hosts so verified custom domains can resolve", () => {
|
||||
|
||||
@@ -11,10 +11,9 @@ export function surfaceForHost(hostname: string, configuredAppHost = ""): Surfac
|
||||
) {
|
||||
return "app";
|
||||
}
|
||||
if (
|
||||
normalized.endsWith(".localhost")
|
||||
|| normalized.endsWith(".help.custocrm.ru")
|
||||
) {
|
||||
// Поддомены localhost — порталы помощи dev-стека; прочие хосты определяет
|
||||
// бэкенд (пробa /api/v1/help/ в main.tsx), зашитых доменов нет.
|
||||
if (normalized.endsWith(".localhost")) {
|
||||
return "help";
|
||||
}
|
||||
return "loading";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@edevs/web-chat",
|
||||
"name": "@chatballs/web-chat",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
@@ -10,9 +10,9 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edevs/contracts": "0.1.0",
|
||||
"@edevs/shared": "0.1.0",
|
||||
"@edevs/ui": "0.1.0",
|
||||
"@chatballs/contracts": "0.1.0",
|
||||
"@chatballs/shared": "0.1.0",
|
||||
"@chatballs/ui": "0.1.0",
|
||||
"antd": "^5.26.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Demo · Edevs Web Chat</title>
|
||||
<title>Demo · Chatballs Web Chat</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; color: #1f1f1f; background: #fbfbfc; }
|
||||
@@ -23,13 +23,13 @@
|
||||
<div style="font-size:13px;color:#595959;">Возможности · Тарифы · Контакты</div>
|
||||
</nav>
|
||||
<div class="hero">
|
||||
<h1>Демо-страница для виджета Edevs</h1>
|
||||
<h1>Демо-страница для виджета Chatballs</h1>
|
||||
<p>Это фейковый сайт для проверки web-чата. Виджет подключён одним тегом снизу — launcher в правом нижнем углу.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
Встраивание: <code><script src="/chat-widget.js" data-channel="edevs" async></script></code>
|
||||
Встраивание: <code><script src="/chat-widget.js" data-channel="demo" async></script></code>
|
||||
</div>
|
||||
|
||||
<script src="/chat-widget.js" data-channel="edevs" async></script>
|
||||
<script src="/chat-widget.js" data-channel="demo" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -25,10 +25,10 @@ const WIDGET_KEY = PARAMS.get("widgetKey") || "";
|
||||
const LEGACY_CHANNEL = PARAMS.get("channel") || "";
|
||||
const ENTRY = WIDGET_KEY ? { widgetKey: WIDGET_KEY } : { channel: LEGACY_CHANNEL };
|
||||
const HOST_ORIGIN = document.referrer ? new URL(document.referrer).origin : location.origin;
|
||||
const TOKEN_KEY = `edevs-chat-token:${WIDGET_KEY || `channel:${LEGACY_CHANNEL}`}`;
|
||||
const TOKEN_KEY = `chatballs-chat-token:${WIDGET_KEY || `channel:${LEGACY_CHANNEL}`}`;
|
||||
|
||||
function closePanel() {
|
||||
window.parent.postMessage({ type: "edevs-chat-close" }, "*");
|
||||
window.parent.postMessage({ type: "chatballs-chat-close" }, "*");
|
||||
}
|
||||
|
||||
export function App() {
|
||||
|
||||
@@ -46,7 +46,7 @@ function requestSupportToken(): Promise<string> {
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
window.parent.postMessage({ type: "edevs-chat-close" }, "*");
|
||||
window.parent.postMessage({ type: "chatballs-chat-close" }, "*");
|
||||
}
|
||||
|
||||
export function SupportApp() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ChatHeader } from "./ChatView";
|
||||
export const SUPPORT_ACCENT = "#1677ff";
|
||||
|
||||
function closePanel() {
|
||||
window.parent.postMessage({ type: "edevs-chat-close" }, "*");
|
||||
window.parent.postMessage({ type: "chatballs-chat-close" }, "*");
|
||||
}
|
||||
|
||||
export function supportShell(): React.CSSProperties {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// (он выполнен в CallApp) — переиспользует загруженные call/accessToken/iceServers,
|
||||
// держит свой аудио-RTC (videoEnabled:false) и поллинг состояния до терминала.
|
||||
|
||||
import { AudioCallView, type AudioCallMode, type AudioCallStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui";
|
||||
import { AudioCallView, type AudioCallMode, type AudioCallStatus, useCallRtcSession, useLoopingAudio } from "@chatballs/ui";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { acceptCall, declineCall, endCall, fetchCallState, type CallInfo } from "../api";
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { AudioCallStage } from "./AudioCallStage";
|
||||
import { VideoCallStage } from "./VideoCallStage";
|
||||
|
||||
function storageKey() { return `edevs-call:${location.pathname}`; }
|
||||
function storageKey() { return `chatballs-call:${location.pathname}`; }
|
||||
function inviteTokenFromPath() { return location.pathname.match(/\/calls\/([^/]+)/)?.[1] ?? ""; }
|
||||
function accessTokenFromHash() { return location.hash.startsWith("#") ? location.hash.slice(1) : ""; }
|
||||
function kindFromQuery(): CallKind | null {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// (он выполнен в CallApp) — переиспользует загруженные call/accessToken/iceServers,
|
||||
// держит свой видео-RTC и поллинг состояния до терминала.
|
||||
|
||||
import { CallView, useCallRtcSession, useLoopingAudio } from "@edevs/ui";
|
||||
import { CallView, useCallRtcSession, useLoopingAudio } from "@chatballs/ui";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { acceptCall, declineCall, endCall, fetchCallState, type CallInfo } from "../api";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type AudioCallMode, type AudioCallStatus, type CallViewMode, type CallViewStatus, buildAudioStatus } from "@edevs/ui";
|
||||
import { type AudioCallMode, type AudioCallStatus, type CallViewMode, type CallViewStatus, buildAudioStatus } from "@chatballs/ui";
|
||||
|
||||
import type { CallInfo } from "../api";
|
||||
|
||||
@@ -44,7 +44,7 @@ export function callViewSubtitle(mode: CallViewMode, status?: CallViewStatus) {
|
||||
}
|
||||
|
||||
// --- Аудиозвонок (baseline «Аудиозвонок.dc.html»): incoming → active → терминал,
|
||||
// без pre-call. Статус-центр — через общий buildAudioStatus из @edevs/ui. ---
|
||||
// без pre-call. Статус-центр — через общий buildAudioStatus из @chatballs/ui. ---
|
||||
|
||||
export function resolveAudioCallViewMode(state: { loading: boolean; invalid: boolean; call: CallInfo | null; started: boolean; connection: string; mediaIssue: string; errorText?: string }): AudioCallMode {
|
||||
if (state.loading || state.invalid || state.errorText || !state.call) return "status";
|
||||
|
||||
@@ -10,9 +10,9 @@ body,
|
||||
}
|
||||
|
||||
/*
|
||||
Дизайн-токены для страницы звонка /calls/<invite> (baseline Edevs Hub DS).
|
||||
Дизайн-токены для страницы звонка /calls/<invite> (дизайн-система Chatballs).
|
||||
На основной странице виджета (встраиваемого) эти переменные не нужны, но экран
|
||||
аудиозвонка из @edevs/ui (audio-call.css) использует var(--font-sans) и радиусы,
|
||||
аудиозвонка из @chatballs/ui (audio-call.css) использует var(--font-sans) и радиусы,
|
||||
которых в web-chat нет. Значения — системный font stack Ant Design и токены
|
||||
радиусов из baseline tokens/typography.css + tokens/effects.css (ADR-HUB-0013:
|
||||
внешние веб-шрифты не подключаются).
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallback, useEffect, type RefObject } from "react";
|
||||
* Лоадер создаёт iframe заранее и прячет его через `display:none`, поэтому при
|
||||
* первом рендере у ленты `scrollHeight === 0` и обычный автоскролл ничего не
|
||||
* делает — панель открывается с началом истории. Поэтому дополнительно слушаем
|
||||
* `edevs-chat-opened` от лоадера и доскроллим уже после показа панели.
|
||||
* `chatballs-chat-opened` от лоадера и доскроллим уже после показа панели.
|
||||
*
|
||||
* Возвращает функцию скролла — компонент вызывает её из своего эффекта на
|
||||
* приход новых сообщений.
|
||||
@@ -21,7 +21,7 @@ export function useScrollToLatest(bodyRef: RefObject<HTMLDivElement | null>): ()
|
||||
let timer = 0;
|
||||
function onParentMessage(event: MessageEvent) {
|
||||
const data = event.data as { type?: string } | null;
|
||||
if (data?.type !== "edevs-chat-opened") return;
|
||||
if (data?.type !== "chatballs-chat-opened") return;
|
||||
scrollToLatest();
|
||||
// Панель только что получила display:block: если размеры ленты ещё не
|
||||
// пересчитаны, повторяем после текущей задачи. Через rAF нельзя — в
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useAudioCue, useLoopingAudio } from "@edevs/ui";
|
||||
import { useAudioCue, useLoopingAudio } from "@chatballs/ui";
|
||||
|
||||
const NOTIFICATION_SOUND = "/chat/audio/notification.mp3";
|
||||
const RINGTONE_SOUND = "/chat/audio/ringtone.mp3";
|
||||
|
||||
type WidgetActivity =
|
||||
| { type: "edevs-chat-activity"; kind: "message" }
|
||||
| { type: "edevs-chat-activity"; kind: "call"; active: boolean };
|
||||
| { type: "chatballs-chat-activity"; kind: "message" }
|
||||
| { type: "chatballs-chat-activity"; kind: "call"; active: boolean };
|
||||
|
||||
function postActivity(activity: WidgetActivity) {
|
||||
window.parent.postMessage(activity, "*");
|
||||
@@ -20,11 +20,11 @@ export function useWidgetActivity(incomingCall: boolean) {
|
||||
useLoopingAudio(RINGTONE_SOUND, !embedded && incomingCall);
|
||||
|
||||
useEffect(() => {
|
||||
if (embedded) postActivity({ type: "edevs-chat-activity", kind: "call", active: incomingCall });
|
||||
if (embedded) postActivity({ type: "chatballs-chat-activity", kind: "call", active: incomingCall });
|
||||
}, [embedded, incomingCall]);
|
||||
|
||||
return () => {
|
||||
if (embedded) postActivity({ type: "edevs-chat-activity", kind: "message" });
|
||||
if (embedded) postActivity({ type: "chatballs-chat-activity", kind: "message" });
|
||||
else playNotification();
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Канонический production-манифест Chatballs (ADR-HUB-0028).
|
||||
# Один Compose для: box-установки клиента, hub.edevs.tech, staging, smoke.
|
||||
# Один Compose для: установки клиента, staging, smoke.
|
||||
# Локальная разработка — через override compose.dev.yaml; этот файл не содержит
|
||||
# dev source mounts, dev-команд и host-портов (кроме public 80/443 у gateway).
|
||||
#
|
||||
|
||||
@@ -19,8 +19,8 @@ COPY packages ./packages
|
||||
ARG VITE_API_BASE_URL=
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
RUN npm --workspace @edevs/internal-ui run build
|
||||
RUN npm --workspace @edevs/web-chat run build
|
||||
RUN npm --workspace @chatballs/internal-ui run build
|
||||
RUN npm --workspace @chatballs/web-chat run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
|
||||
Generated
+23
-23
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@edevs/hub-workspace",
|
||||
"name": "@chatballs/workspace",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@edevs/hub-workspace",
|
||||
"name": "@chatballs/workspace",
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"apps/internal-ui",
|
||||
@@ -23,13 +23,13 @@
|
||||
}
|
||||
},
|
||||
"apps/checkout": {
|
||||
"name": "@edevs/checkout",
|
||||
"name": "@chatballs/checkout",
|
||||
"version": "0.1.0",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"@edevs/contracts": "0.1.0",
|
||||
"@edevs/shared": "0.1.0",
|
||||
"@edevs/ui": "0.1.0",
|
||||
"@chatballs/contracts": "0.1.0",
|
||||
"@chatballs/shared": "0.1.0",
|
||||
"@chatballs/ui": "0.1.0",
|
||||
"antd": "^5.26.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
@@ -40,13 +40,13 @@
|
||||
}
|
||||
},
|
||||
"apps/internal-ui": {
|
||||
"name": "@edevs/internal-ui",
|
||||
"name": "@chatballs/internal-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@edevs/contracts": "0.1.0",
|
||||
"@edevs/shared": "0.1.0",
|
||||
"@edevs/ui": "0.1.0",
|
||||
"@chatballs/contracts": "0.1.0",
|
||||
"@chatballs/shared": "0.1.0",
|
||||
"@chatballs/ui": "0.1.0",
|
||||
"antd": "^5.26.0",
|
||||
"js-yaml": "^5.2.1",
|
||||
"lucide": "^1.41.0",
|
||||
@@ -62,12 +62,12 @@
|
||||
}
|
||||
},
|
||||
"apps/web-chat": {
|
||||
"name": "@edevs/web-chat",
|
||||
"name": "@chatballs/web-chat",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@edevs/contracts": "0.1.0",
|
||||
"@edevs/shared": "0.1.0",
|
||||
"@edevs/ui": "0.1.0",
|
||||
"@chatballs/contracts": "0.1.0",
|
||||
"@chatballs/shared": "0.1.0",
|
||||
"@chatballs/ui": "0.1.0",
|
||||
"antd": "^5.26.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
@@ -461,23 +461,23 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@edevs/contracts": {
|
||||
"node_modules/@chatballs/contracts": {
|
||||
"resolved": "packages/contracts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@edevs/internal-ui": {
|
||||
"node_modules/@chatballs/internal-ui": {
|
||||
"resolved": "apps/internal-ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@edevs/shared": {
|
||||
"node_modules/@chatballs/shared": {
|
||||
"resolved": "packages/shared",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@edevs/ui": {
|
||||
"node_modules/@chatballs/ui": {
|
||||
"resolved": "packages/ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@edevs/web-chat": {
|
||||
"node_modules/@chatballs/web-chat": {
|
||||
"resolved": "apps/web-chat",
|
||||
"link": true
|
||||
},
|
||||
@@ -5011,18 +5011,18 @@
|
||||
}
|
||||
},
|
||||
"packages/contracts": {
|
||||
"name": "@edevs/contracts",
|
||||
"name": "@chatballs/contracts",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@edevs/shared",
|
||||
"name": "@chatballs/shared",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@edevs/ui",
|
||||
"name": "@chatballs/ui",
|
||||
"version": "0.1.0",
|
||||
"peerDependencies": {
|
||||
"@edevs/shared": "0.1.0",
|
||||
"@chatballs/shared": "0.1.0",
|
||||
"antd": "^5.26.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@edevs/hub-workspace",
|
||||
"name": "@chatballs/workspace",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"packageManager": "npm@10.0.0",
|
||||
@@ -11,8 +11,8 @@
|
||||
"packages/shared"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:internal-ui": "npm --workspace @edevs/internal-ui run dev",
|
||||
"dev:web-chat": "npm --workspace @edevs/web-chat run dev",
|
||||
"dev:internal-ui": "npm --workspace @chatballs/internal-ui run dev",
|
||||
"dev:web-chat": "npm --workspace @chatballs/web-chat run dev",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"lint": "npm run lint --workspaces --if-present",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@edevs/contracts",
|
||||
"name": "@chatballs/contracts",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@edevs/shared",
|
||||
"name": "@chatballs/shared",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@edevs/ui",
|
||||
"name": "@chatballs/ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
@@ -9,7 +9,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edevs/shared": "0.1.0",
|
||||
"@chatballs/shared": "0.1.0",
|
||||
"antd": "^5.26.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CallRtcClient, type CallSide, type PublicCallState, type RtcConnectionPhase } from "@edevs/shared";
|
||||
import { CallRtcClient, type CallSide, type PublicCallState, type RtcConnectionPhase } from "@chatballs/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type CallMediaIssue = "none" | "video" | "devices" | "unsupported";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { buildHubTheme, edevsHubTheme } from "./theme";
|
||||
export { buildTheme, chatballsTheme } from "./theme";
|
||||
export { CallView, type CallViewMode, type CallViewStatus } from "./call/CallView";
|
||||
export { AudioCallView } from "./call/AudioCallView";
|
||||
export { type AudioCallMode, type AudioCallStatus, buildAudioStatus, isAudioTerminal, formatDuration as formatAudioDuration } from "./call/audioCallStates";
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { ThemeConfig } from "antd";
|
||||
|
||||
// Персональная тема и акцент (SPEC-HUB-0031 §7): antd-конфиг собирается из
|
||||
// выбранных пользователем режима (light/dark) и акцентного цвета.
|
||||
export function buildHubTheme(dark: boolean, accent: string): ThemeConfig {
|
||||
const base = edevsHubTheme;
|
||||
export function buildTheme(dark: boolean, accent: string): ThemeConfig {
|
||||
const base = chatballsTheme;
|
||||
if (!dark) {
|
||||
return {
|
||||
...base,
|
||||
@@ -33,7 +33,7 @@ export function buildHubTheme(dark: boolean, accent: string): ThemeConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export const edevsHubTheme: ThemeConfig = {
|
||||
export const chatballsTheme: ThemeConfig = {
|
||||
token: {
|
||||
colorPrimary: "#1677ff",
|
||||
colorSuccess: "#52c41a",
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
webServer: {
|
||||
command: "npm --workspace @edevs/internal-ui run dev",
|
||||
command: "npm --workspace @chatballs/internal-ui run dev",
|
||||
url: "http://localhost:5173",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
|
||||
@@ -10,7 +10,7 @@ test.beforeEach(({}, testInfo) => {
|
||||
|
||||
const OWNER = {
|
||||
id: 1,
|
||||
email: "owner@edevs.tech",
|
||||
email: "owner@example.com",
|
||||
fullName: "Владелец",
|
||||
role: "OWNER",
|
||||
positionTitle: "Владелец",
|
||||
@@ -42,7 +42,7 @@ const OPERATOR_CAPABILITIES = ["conversations.view", "conversations.operate", "c
|
||||
const OPERATOR = {
|
||||
...OWNER,
|
||||
id: 2,
|
||||
email: "operator@edevs.tech",
|
||||
email: "operator@example.com",
|
||||
fullName: "Оператор",
|
||||
role: "EMPLOYEE",
|
||||
positionTitle: "Оператор отдела продаж",
|
||||
@@ -86,7 +86,7 @@ async function mockData(page: Page) {
|
||||
canTerminateSessions: true, canTransferOwnership: false,
|
||||
};
|
||||
const employee = {
|
||||
id: 7, email: "d.sokolov@edevs.tech", fullName: "Дмитрий Соколов", phone: "+7 903 118 77 51",
|
||||
id: 7, email: "d.sokolov@example.com", fullName: "Дмитрий Соколов", phone: "+7 903 118 77 51",
|
||||
role: "EMPLOYEE", positionTitle: "Менеджер по продажам", department: "sales", departmentName: "Отдел продаж",
|
||||
createdAt: "2026-05-20T10:00:00Z", lastLogin: "2026-07-13T08:30:00Z", isActive: true, isBlocked: false,
|
||||
mustChangePassword: false, totpRequired: false, totpEnabled: true, permissions,
|
||||
@@ -140,7 +140,7 @@ async function login(page: Page, user: object) {
|
||||
await mockData(page);
|
||||
await page.route("**/api/v1/auth/login/", (route) => route.fulfill({ json: { authenticated: true, user } }));
|
||||
await page.goto("/");
|
||||
await page.getByPlaceholder("you@domain.ru").fill("user@edevs.tech");
|
||||
await page.getByPlaceholder("you@domain.ru").fill("user@example.com");
|
||||
await page.getByPlaceholder("Пароль").fill("Password-123");
|
||||
await page.getByRole("button", { name: "Войти" }).click();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user