🧹 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:
AndreyandClaude Fable 5.1 committed 2026-09-06 00:33:22 +03:00
1 parent 1a61780dec
commit a891947016
85 files changed
+2632 -1560

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
)
+34 -34
View File
@@ -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:
+1 -1
View File
@@ -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>Сколько стоит &laquo;FoxRay&raquo;?</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>Сколько стоит &laquo;FoxRay&raquo;?</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 -22
View File
@@ -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:
+5 -5
View File
@@ -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(
+138 -138
View File
@@ -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,
+274 -137
View File
@@ -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/")
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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
)
+21 -21
View File
@@ -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)
+1 -1
View File
@@ -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,
+13 -13
View File
@@ -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 -4
View File
@@ -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()
+4 -4
View File
@@ -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",
+2 -2
View File
@@ -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])?"
+1 -1
View File
@@ -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);
}
+8 -3
View File
@@ -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 {
+2 -1
View File
@@ -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", () => {
+3 -4
View File
@@ -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";
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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>&lt;script src="/chat-widget.js" data-channel="edevs" async&gt;&lt;/script&gt;</code>
Встраивание: <code>&lt;script src="/chat-widget.js" data-channel="demo" async&gt;&lt;/script&gt;</code>
</div>
<script src="/chat-widget.js" data-channel="edevs" async></script>
<script src="/chat-widget.js" data-channel="demo" async></script>
</body>
</html>
+2 -2
View File
@@ -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() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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";
+2 -2
View File
@@ -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";
+2 -2
View File
@@ -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:
внешние веб-шрифты не подключаются).
+2 -2
View File
@@ -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 нельзя — в
+5 -5
View File
@@ -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
View File
@@ -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).
#
+2 -2
View File
@@ -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
+23 -23
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@edevs/contracts",
"name": "@chatballs/contracts",
"private": true,
"version": "0.1.0",
"type": "module",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@edevs/shared",
"name": "@chatballs/shared",
"private": true,
"version": "0.1.0",
"type": "module",
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -3
View File
@@ -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",
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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();
}