From a89194701666240b5feafa3a26f36d52bc11794e Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 6 Sep 2026 00:33:22 +0300 Subject: [PATCH] =?UTF-8?q?:broom:=20chore!:=20=D0=BF=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BA=D1=82=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D0=BE=D0=BA=20=D0=BA=20=D0=B2=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=BE=D1=80=D1=83=20=E2=80=94=20=D0=B4=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B,=20=D0=BF=D0=BE=D1=87=D1=82=D0=B0,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D1=8B,=20=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D1=83=D0=BF=D1=8B,=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - нет зашитых доменов: 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 --- .env.example | 2 +- Caddyfile | 2 +- .../chatballs/ai/test_agent_attachments.py | 6 +- apps/backend/chatballs/ai/test_agent_cards.py | 12 +- .../ai/test_agent_knowledge_guards.py | 12 +- .../chatballs/ai/test_demo_provider.py | 6 +- .../chatballs/ai/test_knowledge_bulk.py | 14 +- .../ai/test_knowledge_detail_payload.py | 8 +- .../chatballs/ai/test_knowledge_import_v2.py | 16 +- .../chatballs/ai/test_provider_modes.py | 12 +- .../chatballs/ai/test_storage_isolation.py | 10 +- apps/backend/chatballs/ai/tests.py | 68 +- apps/backend/chatballs/calls/tests/helpers.py | 12 +- .../backend/chatballs/calls/tests/test_api.py | 2 +- .../chatballs/calls/tests/test_flow.py | 2 +- .../chatballs/calls/tests/test_turn.py | 4 +- apps/backend/chatballs/channels/models.py | 2 +- .../channels/test_policy_migration.py | 282 +++-- .../chatballs/conversations/clients.py | 17 +- .../conversations/test_email_transport.py | 849 ++++++++----- .../chatballs/conversations/test_lifecycle.py | 570 ++++++--- .../conversations/test_voice_messages.py | 12 +- apps/backend/chatballs/conversations/tests.py | 44 +- apps/backend/chatballs/identity/bootstrap.py | 10 +- .../migrations/0015_organization_status.py | 2 +- .../identity/test_membership_migration.py | 2 +- apps/backend/chatballs/identity/tests.py | 276 ++--- .../chatballs/integrations/test_activation.py | 8 +- .../chatballs/integrations/test_email.py | 411 ++++--- apps/backend/chatballs/integrations/tests.py | 1071 +++++++++++------ apps/backend/chatballs/notifications/tests.py | 16 +- apps/backend/chatballs/platform/views.py | 2 +- apps/backend/chatballs/products/tests.py | 18 +- .../backend/chatballs/support/test_helpers.py | 6 +- .../chatballs/support/test_widget_messages.py | 12 +- apps/backend/chatballs/support/tests.py | 42 +- apps/backend/chatballs/support/token.py | 2 +- .../chatballs/support_portals/tests/base.py | 16 +- .../tests/test_management_api.py | 8 +- .../tests/test_management_content_api.py | 2 +- .../support_portals/tests/test_public_api.py | 22 +- apps/backend/chatballs/webchat/loader.py | 26 +- apps/backend/chatballs/webchat/tests.py | 8 +- .../chatballs_backend/settings_base.py | 10 +- apps/internal-ui/package.json | 8 +- apps/internal-ui/src/App.tsx | 4 +- .../conversations/AudioCallOverlay.tsx | 2 +- .../conversations/VideoCallOverlay.tsx | 2 +- .../conversations/emailContent.test.ts | 4 +- .../conversations/useIncomingMessageSound.ts | 2 +- .../src/features/integrations/model.test.ts | 24 +- .../src/features/sales/SalesClientsPage.tsx | 2 +- .../src/features/sales/client-detail/model.ts | 6 +- .../sales/clients/SalesClientsFilters.tsx | 5 +- .../src/features/sales/clients/model.ts | 30 +- .../features/sales/clients/useSalesClients.ts | 4 +- .../support-portals/PortalCreateDialog.tsx | 4 +- apps/internal-ui/src/layout/Shell.tsx | 2 +- apps/internal-ui/src/shared/utils.ts | 11 +- apps/internal-ui/src/surface.test.ts | 3 +- apps/internal-ui/src/surface.ts | 7 +- apps/web-chat/package.json | 8 +- apps/web-chat/public/demo.html | 8 +- apps/web-chat/src/App.tsx | 4 +- apps/web-chat/src/SupportApp.tsx | 2 +- apps/web-chat/src/SupportStatusScreen.tsx | 2 +- apps/web-chat/src/call/AudioCallStage.tsx | 2 +- apps/web-chat/src/call/CallApp.tsx | 2 +- apps/web-chat/src/call/VideoCallStage.tsx | 2 +- apps/web-chat/src/call/model.ts | 4 +- apps/web-chat/src/styles.css | 4 +- apps/web-chat/src/useScrollToLatest.ts | 4 +- apps/web-chat/src/widgetActivity.ts | 10 +- compose.yaml | 2 +- deploy/docker/frontend.Dockerfile | 4 +- package-lock.json | 46 +- package.json | 6 +- packages/contracts/package.json | 2 +- packages/shared/package.json | 2 +- packages/ui/package.json | 4 +- packages/ui/src/call/useCallRtcSession.ts | 2 +- packages/ui/src/index.ts | 2 +- packages/ui/src/theme.ts | 6 +- playwright.config.ts | 2 +- tests/e2e/internal-ui.spec.ts | 8 +- 85 files changed, 2632 insertions(+), 1560 deletions(-) diff --git a/.env.example b/.env.example index d851fe2..7f275c7 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,7 @@ CHATBALLS_OPENROUTER_API_KEY= # EMAIL_HOST= # EMAIL_HOST_USER= # EMAIL_HOST_PASSWORD= -# DEFAULT_FROM_EMAIL=Chatballs +# DEFAULT_FROM_EMAIL=Chatballs INTERNAL_UI_BASE_URL=http://localhost:5173 POSTGRES_DB=chatballs diff --git a/Caddyfile b/Caddyfile index fb79d31..3e09424 100644 --- a/Caddyfile +++ b/Caddyfile @@ -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 diff --git a/apps/backend/chatballs/ai/test_agent_attachments.py b/apps/backend/chatballs/ai/test_agent_attachments.py index 4b38e74..4b0a0bb 100644 --- a/apps/backend/chatballs/ai/test_agent_attachments.py +++ b/apps/backend/chatballs/ai/test_agent_attachments.py @@ -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 diff --git a/apps/backend/chatballs/ai/test_agent_cards.py b/apps/backend/chatballs/ai/test_agent_cards.py index a935938..330eae7 100644 --- a/apps/backend/chatballs/ai/test_agent_cards.py +++ b/apps/backend/chatballs/ai/test_agent_cards.py @@ -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: diff --git a/apps/backend/chatballs/ai/test_agent_knowledge_guards.py b/apps/backend/chatballs/ai/test_agent_knowledge_guards.py index e2f05b8..16c7b57 100644 --- a/apps/backend/chatballs/ai/test_agent_knowledge_guards.py +++ b/apps/backend/chatballs/ai/test_agent_knowledge_guards.py @@ -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) diff --git a/apps/backend/chatballs/ai/test_demo_provider.py b/apps/backend/chatballs/ai/test_demo_provider.py index 7e9dd76..4176569 100644 --- a/apps/backend/chatballs/ai/test_demo_provider.py +++ b/apps/backend/chatballs/ai/test_demo_provider.py @@ -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: diff --git a/apps/backend/chatballs/ai/test_knowledge_bulk.py b/apps/backend/chatballs/ai/test_knowledge_bulk.py index 53e4a9c..44a1d94 100644 --- a/apps/backend/chatballs/ai/test_knowledge_bulk.py +++ b/apps/backend/chatballs/ai/test_knowledge_bulk.py @@ -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", ) diff --git a/apps/backend/chatballs/ai/test_knowledge_detail_payload.py b/apps/backend/chatballs/ai/test_knowledge_detail_payload.py index 9c2082e..b540be0 100644 --- a/apps/backend/chatballs/ai/test_knowledge_detail_payload.py +++ b/apps/backend/chatballs/ai/test_knowledge_detail_payload.py @@ -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: diff --git a/apps/backend/chatballs/ai/test_knowledge_import_v2.py b/apps/backend/chatballs/ai/test_knowledge_import_v2.py index a816644..7d59bef 100644 --- a/apps/backend/chatballs/ai/test_knowledge_import_v2.py +++ b/apps/backend/chatballs/ai/test_knowledge_import_v2.py @@ -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, diff --git a/apps/backend/chatballs/ai/test_provider_modes.py b/apps/backend/chatballs/ai/test_provider_modes.py index 8d61355..526f31e 100644 --- a/apps/backend/chatballs/ai/test_provider_modes.py +++ b/apps/backend/chatballs/ai/test_provider_modes.py @@ -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" diff --git a/apps/backend/chatballs/ai/test_storage_isolation.py b/apps/backend/chatballs/ai/test_storage_isolation.py index 01ddaf1..26b6288 100644 --- a/apps/backend/chatballs/ai/test_storage_isolation.py +++ b/apps/backend/chatballs/ai/test_storage_isolation.py @@ -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", ) diff --git a/apps/backend/chatballs/ai/tests.py b/apps/backend/chatballs/ai/tests.py index c27ebe6..e1c4707 100644 --- a/apps/backend/chatballs/ai/tests.py +++ b/apps/backend/chatballs/ai/tests.py @@ -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/", diff --git a/apps/backend/chatballs/calls/tests/helpers.py b/apps/backend/chatballs/calls/tests/helpers.py index eda5545..4e09650 100644 --- a/apps/backend/chatballs/calls/tests/helpers.py +++ b/apps/backend/chatballs/calls/tests/helpers.py @@ -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( diff --git a/apps/backend/chatballs/calls/tests/test_api.py b/apps/backend/chatballs/calls/tests/test_api.py index ea01401..5e081f9 100644 --- a/apps/backend/chatballs/calls/tests/test_api.py +++ b/apps/backend/chatballs/calls/tests/test_api.py @@ -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( diff --git a/apps/backend/chatballs/calls/tests/test_flow.py b/apps/backend/chatballs/calls/tests/test_flow.py index 95687fd..095ccca 100644 --- a/apps/backend/chatballs/calls/tests/test_flow.py +++ b/apps/backend/chatballs/calls/tests/test_flow.py @@ -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) diff --git a/apps/backend/chatballs/calls/tests/test_turn.py b/apps/backend/chatballs/calls/tests/test_turn.py index ffd9f8e..e2fcb90 100644 --- a/apps/backend/chatballs/calls/tests/test_turn.py +++ b/apps/backend/chatballs/calls/tests/test_turn.py @@ -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: diff --git a/apps/backend/chatballs/channels/models.py b/apps/backend/chatballs/channels/models.py index 8f11009..37a5aca 100644 --- a/apps/backend/chatballs/channels/models.py +++ b/apps/backend/chatballs/channels/models.py @@ -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) diff --git a/apps/backend/chatballs/channels/test_policy_migration.py b/apps/backend/chatballs/channels/test_policy_migration.py index e93fc3a..8d98939 100644 --- a/apps/backend/chatballs/channels/test_policy_migration.py +++ b/apps/backend/chatballs/channels/test_policy_migration.py @@ -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, + + ) + diff --git a/apps/backend/chatballs/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py index 627ebb7..b09cbee 100644 --- a/apps/backend/chatballs/conversations/clients.py +++ b/apps/backend/chatballs/conversations/clients.py @@ -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(), diff --git a/apps/backend/chatballs/conversations/test_email_transport.py b/apps/backend/chatballs/conversations/test_email_transport.py index 8980e55..c0bf09a 100644 --- a/apps/backend/chatballs/conversations/test_email_transport.py +++ b/apps/backend/chatballs/conversations/test_email_transport.py @@ -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_="Иван Петров ", subject="Вопрос по FoxRay", text="Здравствуйте!", message_id="", 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, "") - 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": ""}) - self.assertEqual(message_id, "") - - 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="

Добрый день!

Сколько стоит «FoxRay»?

")), - own_address="support@edevs.tech", - fallback_id="7:100", - ) - self.assertIsNotNone(inbound) - self.assertIn("Добрый день!", inbound.text) - self.assertIn("«FoxRay»", inbound.text) - self.assertNotIn("

", inbound.text) - self.assertIn("

Добрый день!

", inbound.content_html) - - def test_html_body_is_sanitized_before_ingest(self) -> None: - inbound, _ = email_transport._normalize( - _parsed( - _raw( - html=( - '

Здравствуйте!

' - '' - 'опасная ссылка' - 'сайт' - ) - ) - ), - 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=""), 100: _raw()}) - messages, marker = self._poll(_integration(poll_marker="7:99"), fake) - self.assertEqual([m.external_id for m in messages], [""]) - 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": ""}, - ) - - 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"], "") - self.assertEqual(outgoing["References"], "") - - 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="", subject="Вопрос по FoxRay", message_id="") - self._ingest(external_id="", subject="Re: Вопрос по FoxRay", message_id="") - conversation = Conversation.objects.get(channel=self.channel) - self.assertEqual(conversation.transport_meta["subject"], "Вопрос по FoxRay") - self.assertEqual(conversation.transport_meta["last_message_id"], "") - 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="", - subject="Контакты", - message_id="", - ) - 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_="Иван Петров ", subject="Вопрос по FoxRay", text="Здравствуйте!", message_id="", 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, "") + + 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": ""}) + + self.assertEqual(message_id, "") + + + + 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="

Добрый день!

Сколько стоит «FoxRay»?

")), + + own_address="support@example.com", + + fallback_id="7:100", + + ) + + self.assertIsNotNone(inbound) + + self.assertIn("Добрый день!", inbound.text) + + self.assertIn("«FoxRay»", inbound.text) + + self.assertNotIn("

", inbound.text) + + self.assertIn("

Добрый день!

", inbound.content_html) + + + + def test_html_body_is_sanitized_before_ingest(self) -> None: + + inbound, _ = email_transport._normalize( + + _parsed( + + _raw( + + html=( + + '

Здравствуйте!

' + + '' + + 'опасная ссылка' + + 'сайт' + + ) + + ) + + ), + + 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=""), 100: _raw()}) + + messages, marker = self._poll(_integration(poll_marker="7:99"), fake) + + self.assertEqual([m.external_id for m in messages], [""]) + + 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": ""}, + + ) + + + + 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"], "") + + self.assertEqual(outgoing["References"], "") + + + + 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="", subject="Вопрос по FoxRay", message_id="") + + self._ingest(external_id="", subject="Re: Вопрос по FoxRay", message_id="") + + conversation = Conversation.objects.get(channel=self.channel) + + self.assertEqual(conversation.transport_meta["subject"], "Вопрос по FoxRay") + + self.assertEqual(conversation.transport_meta["last_message_id"], "") + + 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="", + + subject="Контакты", + + message_id="", + + ) + + 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") + diff --git a/apps/backend/chatballs/conversations/test_lifecycle.py b/apps/backend/chatballs/conversations/test_lifecycle.py index fd3da31..f3da8eb 100644 --- a/apps/backend/chatballs/conversations/test_lifecycle.py +++ b/apps/backend/chatballs/conversations/test_lifecycle.py @@ -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 + + ) + diff --git a/apps/backend/chatballs/conversations/test_voice_messages.py b/apps/backend/chatballs/conversations/test_voice_messages.py index 92784d9..720418b 100644 --- a/apps/backend/chatballs/conversations/test_voice_messages.py +++ b/apps/backend/chatballs/conversations/test_voice_messages.py @@ -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"] diff --git a/apps/backend/chatballs/conversations/tests.py b/apps/backend/chatballs/conversations/tests.py index 83fb8ec..dfa6240 100644 --- a/apps/backend/chatballs/conversations/tests.py +++ b/apps/backend/chatballs/conversations/tests.py @@ -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: diff --git a/apps/backend/chatballs/identity/bootstrap.py b/apps/backend/chatballs/identity/bootstrap.py index f06d834..375053f 100644 --- a/apps/backend/chatballs/identity/bootstrap.py +++ b/apps/backend/chatballs/identity/bootstrap.py @@ -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, diff --git a/apps/backend/chatballs/identity/migrations/0015_organization_status.py b/apps/backend/chatballs/identity/migrations/0015_organization_status.py index 4a9d342..d34fa4d 100644 --- a/apps/backend/chatballs/identity/migrations/0015_organization_status.py +++ b/apps/backend/chatballs/identity/migrations/0015_organization_status.py @@ -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, diff --git a/apps/backend/chatballs/identity/test_membership_migration.py b/apps/backend/chatballs/identity/test_membership_migration.py index 7fe06f3..ce19118 100644 --- a/apps/backend/chatballs/identity/test_membership_migration.py +++ b/apps/backend/chatballs/identity/test_membership_migration.py @@ -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( diff --git a/apps/backend/chatballs/identity/tests.py b/apps/backend/chatballs/identity/tests.py index 1cac0ee..1826601 100644 --- a/apps/backend/chatballs/identity/tests.py +++ b/apps/backend/chatballs/identity/tests.py @@ -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"]) diff --git a/apps/backend/chatballs/integrations/test_activation.py b/apps/backend/chatballs/integrations/test_activation.py index abd9940..e5ae4b6 100644 --- a/apps/backend/chatballs/integrations/test_activation.py +++ b/apps/backend/chatballs/integrations/test_activation.py @@ -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, diff --git a/apps/backend/chatballs/integrations/test_email.py b/apps/backend/chatballs/integrations/test_email.py index 06d0192..83f49e1 100644 --- a/apps/backend/chatballs/integrations/test_email.py +++ b/apps/backend/chatballs/integrations/test_email.py @@ -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, "") + diff --git a/apps/backend/chatballs/integrations/tests.py b/apps/backend/chatballs/integrations/tests.py index bd82330..008c395 100644 --- a/apps/backend/chatballs/integrations/tests.py +++ b/apps/backend/chatballs/integrations/tests.py @@ -1,357 +1,714 @@ -import json -import urllib.request -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 Integration, IntegrationKind, IntegrationProvider, IntegrationStatus -from chatballs.integrations.serializers import integration_payload -from chatballs.integrations.services import ( - IntegrationInput, - create_integration, - test_integration as run_integration_test, - update_integration, -) -from chatballs.testing import system_tenant_context - - -def _fake_response(status: int, body: dict): - response = mock.MagicMock() - response.status = status - response.read.return_value = json.dumps(body).encode("utf-8") - return response - - -class WebIntegrationCheckTests(TestCase): - """«Проверить» для Web-виджета: внешнего API нет — валидируем привязку к - каналу и самостоятельный widget entry point подключения.""" - - def setUp(self) -> None: - bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") - self.organization = Organization.objects.get(slug="edevs") - self.context = system_tenant_context(self.organization) - from chatballs.channels.models import Channel - - self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт") - - def _web(self, name: str, channel=None, origins=("edevs.tech",)) -> Integration: - return create_integration( - context=self.context, - data=IntegrationInput( - provider=IntegrationProvider.WEB, - name=name, - channel_id=channel.id if channel else None, - config={"allowedOrigins": list(origins)}, - ), - ) - - def test_web_without_channel_fails(self) -> None: - integration = run_integration_test( - context=self.context, integration=self._web("Виджет", channel=None) - ) - self.assertEqual(integration.status, IntegrationStatus.ERROR) - self.assertIn("не привязано к каналу", integration.last_error) - - def test_web_bound_to_channel_is_ok(self) -> None: - integration = run_integration_test( - context=self.context, integration=self._web("Виджет", channel=self.channel) - ) - self.assertEqual(integration.status, IntegrationStatus.OK) - self.assertEqual(integration.last_error, "") - self.assertEqual(integration.web_chat_widget.status, "PUBLISHED") - self.assertTrue(integration_payload(integration)["webChatWidget"]["publicKey"]) - - def test_two_web_connections_on_one_channel_are_independent(self) -> None: - first = run_integration_test( - context=self.context, - integration=self._web("A-виджет", channel=self.channel), - ) - second = run_integration_test( - context=self.context, integration=self._web("B-виджет", channel=self.channel) - ) - self.assertEqual(first.status, IntegrationStatus.OK) - self.assertEqual(second.status, IntegrationStatus.OK) - self.assertNotEqual( - first.web_chat_widget.public_key, - second.web_chat_widget.public_key, - ) - - def test_web_without_allowed_origins_fails(self) -> None: - """Пустой allowed_origins в проде запрещает все домены, и виджет молча - показывает «Чат временно недоступен» — проверка обязана это ловить.""" - integration = run_integration_test( - context=self.context, - integration=self._web("Виджет", channel=self.channel, origins=()), - ) - self.assertEqual(integration.status, IntegrationStatus.ERROR) - self.assertIn("Не заданы разрешённые домены", integration.last_error) - self.assertEqual(integration.web_chat_widget.status, "DRAFT") - - def test_allowed_origins_reach_the_widget(self) -> None: - integration = run_integration_test( - context=self.context, - integration=self._web( - "Виджет", channel=self.channel, origins=("edevs.tech", "*.edevs.tech") - ), - ) - self.assertEqual(integration.status, IntegrationStatus.OK) - self.assertEqual( - integration.web_chat_widget.allowed_origins, ["edevs.tech", "*.edevs.tech"] - ) - self.assertEqual( - integration_payload(integration)["config"]["allowedOrigins"], - ["edevs.tech", "*.edevs.tech"], - ) - - def test_saving_connection_keeps_the_widget_published(self) -> None: - """Регрессия: раздача виджета требует PUBLISHED + OK (webchat.views), а правка - подключения не должна ни ронять чат на сайте до ручного «Проверить», ни - обнулять домены — именно так виджеты уходили в «Чат временно недоступен».""" - integration = self._web("Виджет", channel=self.channel) - self.assertEqual(integration.status, IntegrationStatus.OK) - self.assertEqual(integration.web_chat_widget.status, "PUBLISHED") - - renamed = update_integration( - context=self.context, - integration=integration, - data=IntegrationInput( - provider=IntegrationProvider.WEB, - name="Виджет · переименован", - channel_id=self.channel.id, - config={"allowedOrigins": ["edevs.tech"]}, - ), - ) - self.assertEqual(renamed.status, IntegrationStatus.OK) - self.assertEqual(renamed.web_chat_widget.status, "PUBLISHED") - self.assertEqual(renamed.config["allowed_domains"], ["edevs.tech"]) - self.assertEqual(renamed.web_chat_widget.allowed_origins, ["edevs.tech"]) - -class ProxyConfigTests(TestCase): - def setUp(self) -> None: - bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") - self.organization = Organization.objects.get(slug="edevs") - self.context = system_tenant_context(self.organization) - - def test_proxy_url_is_persisted_in_config(self) -> None: - integration = create_integration( - context=self.context, - data=IntegrationInput( - provider=IntegrationProvider.OPENROUTER, - name="OpenRouter", - secret="sk-test", - config={"baseUrl": "https://openrouter.ai/api/v1", "proxyUrl": "http://user:pass@host:8080"}, - ), - ) - self.assertEqual(integration.config["proxy_url"], "http://user:pass@host:8080") - - def test_update_clears_proxy_when_empty(self) -> None: - integration = create_integration( - context=self.context, - data=IntegrationInput( - provider=IntegrationProvider.OPENROUTER, - name="OpenRouter", - secret="sk-test", - config={"proxyUrl": "http://host:8080"}, - ), - ) - updated = update_integration( - context=self.context, - integration=integration, - data=IntegrationInput(provider=IntegrationProvider.OPENROUTER, name="OpenRouter", config={"proxyUrl": ""}), - ) - self.assertNotIn("proxy_url", updated.config) - - def test_serializer_exposes_proxy_url(self) -> None: - integration = create_integration( - context=self.context, - data=IntegrationInput( - provider=IntegrationProvider.OPENROUTER, - name="OpenRouter", - secret="sk-test", - config={"proxyUrl": "http://host:8080"}, - ), - ) - self.assertEqual(integration_payload(integration)["config"]["proxyUrl"], "http://host:8080") - - -def _patched_opener(captured: dict, body: dict): - def fake_build_opener(proxy_url): - captured["proxy_url"] = proxy_url - opener = mock.MagicMock() - ctx = mock.MagicMock() - ctx.__enter__.return_value = _fake_response(200, body) - opener.open.return_value = ctx - return opener - - return fake_build_opener - - -class CheckProxyTransportTests(TestCase): - """check_* должны прокидывать proxy_url в единый opener (build_opener).""" - - def test_check_openrouter_uses_proxy(self) -> None: - captured = {} - with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})): - ok, detail, meta = checks.check_openrouter(secret="sk-test", base_url="", proxy_url="http://proxy:8080") - - self.assertTrue(ok) - self.assertEqual(captured["proxy_url"], "http://proxy:8080") - - def test_check_openrouter_without_proxy_passes_empty(self) -> None: - captured = {} - with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})): - checks.check_openrouter(secret="sk-test", base_url="", proxy_url="") - - self.assertEqual(captured["proxy_url"], "") - - def test_check_openrouter_supports_socks_url(self) -> None: - captured = {} - with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})): - ok, detail, meta = checks.check_openrouter(secret="sk-test", base_url="", proxy_url="socks5://proxy:1080") - - self.assertTrue(ok) - self.assertEqual(captured["proxy_url"], "socks5://proxy:1080") - - -class OpenRouterProviderProxyTests(TestCase): - def test_provider_routes_through_proxy_handler(self) -> None: - from chatballs.ai.provider.openrouter import OpenRouterProvider - - captured = {} - provider = OpenRouterProvider(api_key="sk-test", base_url="https://openrouter.ai/api/v1", proxy_url="http://proxy:8080") - # После рефакторинга общий HTTP-слой живёт в openai_http (ADR-HUB-0033 §7): - # мокаем именно его build_opener. - with mock.patch("chatballs.ai.provider.openai_http.build_opener", side_effect=_patched_opener(captured, {"choices": [{"message": {"content": "ok"}}], "model": "x"})): - result = provider.chat(messages=[], model="x") - - self.assertEqual(result.text, "ok") - self.assertEqual(captured["proxy_url"], "http://proxy:8080") - - -class BuildOpenerSocksTests(TestCase): - def test_http_scheme_uses_proxy_handler(self) -> None: - from chatballs.integrations.proxy import build_opener - - opener = build_opener("http://proxy:8080") - self.assertTrue(any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers)) - - def test_socks5_scheme_builds_socks_handler(self) -> None: - from chatballs.integrations.proxy import build_opener - - opener = build_opener("socks5://user:pass@host:1080") - # PySocks установлен → handler строится без ошибок и не является ProxyHandler. - self.assertFalse(any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers)) - - def test_socks_without_pysocks_raises_value_error(self) -> None: - from chatballs.integrations import proxy - from chatballs.integrations.proxy import build_opener - - with mock.patch.object(proxy, "_import_socks", side_effect=ValueError("no PySocks")): - with self.assertRaises(ValueError): - build_opener("socks5://host:1080") - - -class CustomIntegrationTests(TestCase): - """Generic OpenAI-compatible BYOK provider (ADR-HUB-0034, SPEC-HUB-0024 §5). - - Covers the three required fields (endpoint, API key, model), runtime - reading of the model field (SPEC #2, #5 — closing the as-built gap where - «Модель по умолчанию» was decorative), and the connectivity check against - an arbitrary OpenAI-compatible endpoint. - """ - - def setUp(self) -> None: - bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") - self.organization = Organization.objects.get(slug="edevs") - self.context = system_tenant_context(self.organization) - - def test_custom_persists_endpoint_and_model(self) -> None: - integration = create_integration( - context=self.context, - data=IntegrationInput( - provider=IntegrationProvider.CUSTOM, - name="Мой провайдер", - secret="sk-custom", - config={ - "baseUrl": "https://api.example.com/v1", - "defaultModel": "local-llama-3", - }, - ), - ) - # Модель — отдельное рабочее поле (ADR-HUB-0034 §4), читается в рантайме. - self.assertEqual(integration.config["base_url"], "https://api.example.com/v1") - self.assertEqual(integration.config["default_model"], "local-llama-3") - self.assertEqual(integration.kind, IntegrationKind.LLM_PROVIDER) - - def test_custom_requires_model_free_text(self) -> None: - with self.assertRaises(ValidationError): - create_integration( - context=self.context, - data=IntegrationInput( - provider=IntegrationProvider.CUSTOM, - name="Мой провайдер", - secret="sk-custom", - config={"baseUrl": "https://api.example.com/v1"}, - ), - ) - - def test_custom_requires_base_url_and_api_key(self) -> None: - with self.assertRaises(ValidationError): - create_integration( - context=self.context, - data=IntegrationInput( - provider=IntegrationProvider.CUSTOM, - name="Без endpoint", - secret="sk-custom", - config={"defaultModel": "local-model"}, - ), - ) - with self.assertRaises(ValidationError): - create_integration( - context=self.context, - data=IntegrationInput( - provider=IntegrationProvider.CUSTOM, - name="Без ключа", - secret="", - config={ - "baseUrl": "https://api.example.com/v1", - "defaultModel": "local-model", - }, - ), - ) - - def test_check_custom_lists_models_on_openai_shape(self) -> None: - captured = {} - with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": [{"id": "local-llama-3"}]})): - ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="https://api.example.com/v1") - - self.assertTrue(ok) - self.assertIn("1 моделей", detail) - - def test_check_custom_without_secret_fails(self) -> None: - ok, detail, meta = checks.check_custom(secret="", base_url="https://api.example.com/v1") - self.assertFalse(ok) - self.assertEqual(detail, "Не указан API-ключ") - - def test_check_custom_without_base_url_fails(self) -> None: - ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="") - self.assertFalse(ok) - self.assertEqual(detail, "Не указан Base URL") - - def test_check_custom_non_200_is_error(self) -> None: - response = mock.MagicMock() - response.status = 401 - response.read.return_value = b"{}" - opener = mock.MagicMock() - ctx = mock.MagicMock() - ctx.__enter__.return_value = response - opener.open.return_value = ctx - with mock.patch("chatballs.integrations.checks.build_opener", return_value=opener): - ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="https://api.example.com/v1") - - self.assertFalse(ok) - self.assertIn("401", detail) +import json + +import urllib.request + +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 Integration, IntegrationKind, IntegrationProvider, IntegrationStatus + +from chatballs.integrations.serializers import integration_payload + +from chatballs.integrations.services import ( + + IntegrationInput, + + create_integration, + + test_integration as run_integration_test, + + update_integration, + +) + +from chatballs.testing import system_tenant_context + + + + + +def _fake_response(status: int, body: dict): + + response = mock.MagicMock() + + response.status = status + + response.read.return_value = json.dumps(body).encode("utf-8") + + return response + + + + + +class WebIntegrationCheckTests(TestCase): + + """«Проверить» для Web-виджета: внешнего API нет — валидируем привязку к + + каналу и самостоятельный widget entry point подключения.""" + + + + def setUp(self) -> None: + + bootstrap_owner(email="owner@example.com", password="temporary-password") + + self.organization = Organization.objects.get(slug="demo") + + self.context = system_tenant_context(self.organization) + + from chatballs.channels.models import Channel + + + + self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт") + + + + def _web(self, name: str, channel=None, origins=("example.com",)) -> Integration: + + return create_integration( + + context=self.context, + + data=IntegrationInput( + + provider=IntegrationProvider.WEB, + + name=name, + + channel_id=channel.id if channel else None, + + config={"allowedOrigins": list(origins)}, + + ), + + ) + + + + def test_web_without_channel_fails(self) -> None: + + integration = run_integration_test( + + context=self.context, integration=self._web("Виджет", channel=None) + + ) + + self.assertEqual(integration.status, IntegrationStatus.ERROR) + + self.assertIn("не привязано к каналу", integration.last_error) + + + + def test_web_bound_to_channel_is_ok(self) -> None: + + integration = run_integration_test( + + context=self.context, integration=self._web("Виджет", channel=self.channel) + + ) + + self.assertEqual(integration.status, IntegrationStatus.OK) + + self.assertEqual(integration.last_error, "") + + self.assertEqual(integration.web_chat_widget.status, "PUBLISHED") + + self.assertTrue(integration_payload(integration)["webChatWidget"]["publicKey"]) + + + + def test_two_web_connections_on_one_channel_are_independent(self) -> None: + + first = run_integration_test( + + context=self.context, + + integration=self._web("A-виджет", channel=self.channel), + + ) + + second = run_integration_test( + + context=self.context, integration=self._web("B-виджет", channel=self.channel) + + ) + + self.assertEqual(first.status, IntegrationStatus.OK) + + self.assertEqual(second.status, IntegrationStatus.OK) + + self.assertNotEqual( + + first.web_chat_widget.public_key, + + second.web_chat_widget.public_key, + + ) + + + + def test_web_without_allowed_origins_fails(self) -> None: + + """Пустой allowed_origins в проде запрещает все домены, и виджет молча + + показывает «Чат временно недоступен» — проверка обязана это ловить.""" + + integration = run_integration_test( + + context=self.context, + + integration=self._web("Виджет", channel=self.channel, origins=()), + + ) + + self.assertEqual(integration.status, IntegrationStatus.ERROR) + + self.assertIn("Не заданы разрешённые домены", integration.last_error) + + self.assertEqual(integration.web_chat_widget.status, "DRAFT") + + + + def test_allowed_origins_reach_the_widget(self) -> None: + + integration = run_integration_test( + + context=self.context, + + integration=self._web( + + "Виджет", channel=self.channel, origins=("example.com", "*.example.com") + + ), + + ) + + self.assertEqual(integration.status, IntegrationStatus.OK) + + self.assertEqual( + + integration.web_chat_widget.allowed_origins, ["example.com", "*.example.com"] + + ) + + self.assertEqual( + + integration_payload(integration)["config"]["allowedOrigins"], + + ["example.com", "*.example.com"], + + ) + + + + def test_saving_connection_keeps_the_widget_published(self) -> None: + + """Регрессия: раздача виджета требует PUBLISHED + OK (webchat.views), а правка + + подключения не должна ни ронять чат на сайте до ручного «Проверить», ни + + обнулять домены — именно так виджеты уходили в «Чат временно недоступен».""" + + integration = self._web("Виджет", channel=self.channel) + + self.assertEqual(integration.status, IntegrationStatus.OK) + + self.assertEqual(integration.web_chat_widget.status, "PUBLISHED") + + + + renamed = update_integration( + + context=self.context, + + integration=integration, + + data=IntegrationInput( + + provider=IntegrationProvider.WEB, + + name="Виджет · переименован", + + channel_id=self.channel.id, + + config={"allowedOrigins": ["example.com"]}, + + ), + + ) + + self.assertEqual(renamed.status, IntegrationStatus.OK) + + self.assertEqual(renamed.web_chat_widget.status, "PUBLISHED") + + self.assertEqual(renamed.config["allowed_domains"], ["example.com"]) + + self.assertEqual(renamed.web_chat_widget.allowed_origins, ["example.com"]) + + + +class ProxyConfigTests(TestCase): + + def setUp(self) -> None: + + 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_proxy_url_is_persisted_in_config(self) -> None: + + integration = create_integration( + + context=self.context, + + data=IntegrationInput( + + provider=IntegrationProvider.OPENROUTER, + + name="OpenRouter", + + secret="sk-test", + + config={"baseUrl": "https://openrouter.ai/api/v1", "proxyUrl": "http://user:pass@host:8080"}, + + ), + + ) + + self.assertEqual(integration.config["proxy_url"], "http://user:pass@host:8080") + + + + def test_update_clears_proxy_when_empty(self) -> None: + + integration = create_integration( + + context=self.context, + + data=IntegrationInput( + + provider=IntegrationProvider.OPENROUTER, + + name="OpenRouter", + + secret="sk-test", + + config={"proxyUrl": "http://host:8080"}, + + ), + + ) + + updated = update_integration( + + context=self.context, + + integration=integration, + + data=IntegrationInput(provider=IntegrationProvider.OPENROUTER, name="OpenRouter", config={"proxyUrl": ""}), + + ) + + self.assertNotIn("proxy_url", updated.config) + + + + def test_serializer_exposes_proxy_url(self) -> None: + + integration = create_integration( + + context=self.context, + + data=IntegrationInput( + + provider=IntegrationProvider.OPENROUTER, + + name="OpenRouter", + + secret="sk-test", + + config={"proxyUrl": "http://host:8080"}, + + ), + + ) + + self.assertEqual(integration_payload(integration)["config"]["proxyUrl"], "http://host:8080") + + + + + +def _patched_opener(captured: dict, body: dict): + + def fake_build_opener(proxy_url): + + captured["proxy_url"] = proxy_url + + opener = mock.MagicMock() + + ctx = mock.MagicMock() + + ctx.__enter__.return_value = _fake_response(200, body) + + opener.open.return_value = ctx + + return opener + + + + return fake_build_opener + + + + + +class CheckProxyTransportTests(TestCase): + + """check_* должны прокидывать proxy_url в единый opener (build_opener).""" + + + + def test_check_openrouter_uses_proxy(self) -> None: + + captured = {} + + with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})): + + ok, detail, meta = checks.check_openrouter(secret="sk-test", base_url="", proxy_url="http://proxy:8080") + + + + self.assertTrue(ok) + + self.assertEqual(captured["proxy_url"], "http://proxy:8080") + + + + def test_check_openrouter_without_proxy_passes_empty(self) -> None: + + captured = {} + + with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})): + + checks.check_openrouter(secret="sk-test", base_url="", proxy_url="") + + + + self.assertEqual(captured["proxy_url"], "") + + + + def test_check_openrouter_supports_socks_url(self) -> None: + + captured = {} + + with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": {"label": "ok"}})): + + ok, detail, meta = checks.check_openrouter(secret="sk-test", base_url="", proxy_url="socks5://proxy:1080") + + + + self.assertTrue(ok) + + self.assertEqual(captured["proxy_url"], "socks5://proxy:1080") + + + + + +class OpenRouterProviderProxyTests(TestCase): + + def test_provider_routes_through_proxy_handler(self) -> None: + + from chatballs.ai.provider.openrouter import OpenRouterProvider + + + + captured = {} + + provider = OpenRouterProvider(api_key="sk-test", base_url="https://openrouter.ai/api/v1", proxy_url="http://proxy:8080") + + # После рефакторинга общий HTTP-слой живёт в openai_http (ADR-HUB-0033 §7): + + # мокаем именно его build_opener. + + with mock.patch("chatballs.ai.provider.openai_http.build_opener", side_effect=_patched_opener(captured, {"choices": [{"message": {"content": "ok"}}], "model": "x"})): + + result = provider.chat(messages=[], model="x") + + + + self.assertEqual(result.text, "ok") + + self.assertEqual(captured["proxy_url"], "http://proxy:8080") + + + + + +class BuildOpenerSocksTests(TestCase): + + def test_http_scheme_uses_proxy_handler(self) -> None: + + from chatballs.integrations.proxy import build_opener + + + + opener = build_opener("http://proxy:8080") + + self.assertTrue(any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers)) + + + + def test_socks5_scheme_builds_socks_handler(self) -> None: + + from chatballs.integrations.proxy import build_opener + + + + opener = build_opener("socks5://user:pass@host:1080") + + # PySocks установлен → handler строится без ошибок и не является ProxyHandler. + + self.assertFalse(any(isinstance(h, urllib.request.ProxyHandler) for h in opener.handlers)) + + + + def test_socks_without_pysocks_raises_value_error(self) -> None: + + from chatballs.integrations import proxy + + from chatballs.integrations.proxy import build_opener + + + + with mock.patch.object(proxy, "_import_socks", side_effect=ValueError("no PySocks")): + + with self.assertRaises(ValueError): + + build_opener("socks5://host:1080") + + + + + +class CustomIntegrationTests(TestCase): + + """Generic OpenAI-compatible BYOK provider (ADR-HUB-0034, SPEC-HUB-0024 §5). + + + + Covers the three required fields (endpoint, API key, model), runtime + + reading of the model field (SPEC #2, #5 — closing the as-built gap where + + «Модель по умолчанию» was decorative), and the connectivity check against + + an arbitrary OpenAI-compatible endpoint. + + """ + + + + def setUp(self) -> None: + + 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_custom_persists_endpoint_and_model(self) -> None: + + integration = create_integration( + + context=self.context, + + data=IntegrationInput( + + provider=IntegrationProvider.CUSTOM, + + name="Мой провайдер", + + secret="sk-custom", + + config={ + + "baseUrl": "https://api.example.com/v1", + + "defaultModel": "local-llama-3", + + }, + + ), + + ) + + # Модель — отдельное рабочее поле (ADR-HUB-0034 §4), читается в рантайме. + + self.assertEqual(integration.config["base_url"], "https://api.example.com/v1") + + self.assertEqual(integration.config["default_model"], "local-llama-3") + + self.assertEqual(integration.kind, IntegrationKind.LLM_PROVIDER) + + + + def test_custom_requires_model_free_text(self) -> None: + + with self.assertRaises(ValidationError): + + create_integration( + + context=self.context, + + data=IntegrationInput( + + provider=IntegrationProvider.CUSTOM, + + name="Мой провайдер", + + secret="sk-custom", + + config={"baseUrl": "https://api.example.com/v1"}, + + ), + + ) + + + + def test_custom_requires_base_url_and_api_key(self) -> None: + + with self.assertRaises(ValidationError): + + create_integration( + + context=self.context, + + data=IntegrationInput( + + provider=IntegrationProvider.CUSTOM, + + name="Без endpoint", + + secret="sk-custom", + + config={"defaultModel": "local-model"}, + + ), + + ) + + with self.assertRaises(ValidationError): + + create_integration( + + context=self.context, + + data=IntegrationInput( + + provider=IntegrationProvider.CUSTOM, + + name="Без ключа", + + secret="", + + config={ + + "baseUrl": "https://api.example.com/v1", + + "defaultModel": "local-model", + + }, + + ), + + ) + + + + def test_check_custom_lists_models_on_openai_shape(self) -> None: + + captured = {} + + with mock.patch("chatballs.integrations.checks.build_opener", side_effect=_patched_opener(captured, {"data": [{"id": "local-llama-3"}]})): + + ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="https://api.example.com/v1") + + + + self.assertTrue(ok) + + self.assertIn("1 моделей", detail) + + + + def test_check_custom_without_secret_fails(self) -> None: + + ok, detail, meta = checks.check_custom(secret="", base_url="https://api.example.com/v1") + + self.assertFalse(ok) + + self.assertEqual(detail, "Не указан API-ключ") + + + + def test_check_custom_without_base_url_fails(self) -> None: + + ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="") + + self.assertFalse(ok) + + self.assertEqual(detail, "Не указан Base URL") + + + + def test_check_custom_non_200_is_error(self) -> None: + + response = mock.MagicMock() + + response.status = 401 + + response.read.return_value = b"{}" + + opener = mock.MagicMock() + + ctx = mock.MagicMock() + + ctx.__enter__.return_value = response + + opener.open.return_value = ctx + + with mock.patch("chatballs.integrations.checks.build_opener", return_value=opener): + + ok, detail, meta = checks.check_custom(secret="sk-custom", base_url="https://api.example.com/v1") + + + + self.assertFalse(ok) + + self.assertIn("401", detail) + diff --git a/apps/backend/chatballs/notifications/tests.py b/apps/backend/chatballs/notifications/tests.py index ff1289e..84ac254 100644 --- a/apps/backend/chatballs/notifications/tests.py +++ b/apps/backend/chatballs/notifications/tests.py @@ -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/") diff --git a/apps/backend/chatballs/platform/views.py b/apps/backend/chatballs/platform/views.py index 4f656e5..34505fb 100644 --- a/apps/backend/chatballs/platform/views.py +++ b/apps/backend/chatballs/platform/views.py @@ -17,7 +17,7 @@ _IDEMPOTENCY_HEADER = "Idempotency-Key" class OrganizationProvisionView(APIView): - """POST platform.custocrm.ru/api/v1/organizations (SPEC-HUB-0021 §12). + """POST /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 diff --git a/apps/backend/chatballs/products/tests.py b/apps/backend/chatballs/products/tests.py index 7c01d72..464cc02 100644 --- a/apps/backend/chatballs/products/tests.py +++ b/apps/backend/chatballs/products/tests.py @@ -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/") diff --git a/apps/backend/chatballs/support/test_helpers.py b/apps/backend/chatballs/support/test_helpers.py index 73d68f7..d90b71f 100644 --- a/apps/backend/chatballs/support/test_helpers.py +++ b/apps/backend/chatballs/support/test_helpers.py @@ -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"} diff --git a/apps/backend/chatballs/support/test_widget_messages.py b/apps/backend/chatballs/support/test_widget_messages.py index 2530984..1f980e1 100644 --- a/apps/backend/chatballs/support/test_widget_messages.py +++ b/apps/backend/chatballs/support/test_widget_messages.py @@ -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 ) diff --git a/apps/backend/chatballs/support/tests.py b/apps/backend/chatballs/support/tests.py index 9827219..1ab71a0 100644 --- a/apps/backend/chatballs/support/tests.py +++ b/apps/backend/chatballs/support/tests.py @@ -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) diff --git a/apps/backend/chatballs/support/token.py b/apps/backend/chatballs/support/token.py index 9c81e5b..a18ef25 100644 --- a/apps/backend/chatballs/support/token.py +++ b/apps/backend/chatballs/support/token.py @@ -21,7 +21,7 @@ from typing import Any from chatballs.support import errors -SUPPORT_AUDIENCE = "edevshub.support" +SUPPORT_AUDIENCE = "chatballs.support" @dataclass(frozen=True) diff --git a/apps/backend/chatballs/support_portals/tests/base.py b/apps/backend/chatballs/support_portals/tests/base.py index abb60f5..0bef2d7 100644 --- a/apps/backend/chatballs/support_portals/tests/base.py +++ b/apps/backend/chatballs/support_portals/tests/base.py @@ -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", ) diff --git a/apps/backend/chatballs/support_portals/tests/test_management_api.py b/apps/backend/chatballs/support_portals/tests/test_management_api.py index 84f6def..83d6987 100644 --- a/apps/backend/chatballs/support_portals/tests/test_management_api.py +++ b/apps/backend/chatballs/support_portals/tests/test_management_api.py @@ -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/") diff --git a/apps/backend/chatballs/support_portals/tests/test_management_content_api.py b/apps/backend/chatballs/support_portals/tests/test_management_content_api.py index ab86a97..28fea9f 100644 --- a/apps/backend/chatballs/support_portals/tests/test_management_content_api.py +++ b/apps/backend/chatballs/support_portals/tests/test_management_content_api.py @@ -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/" ) diff --git a/apps/backend/chatballs/support_portals/tests/test_public_api.py b/apps/backend/chatballs/support_portals/tests/test_public_api.py index 91e4487..487c3e3 100644 --- a/apps/backend/chatballs/support_portals/tests/test_public_api.py +++ b/apps/backend/chatballs/support_portals/tests/test_public_api.py @@ -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, diff --git a/apps/backend/chatballs/webchat/loader.py b/apps/backend/chatballs/webchat/loader.py index 73fda91..68b2afa 100644 --- a/apps/backend/chatballs/webchat/loader.py +++ b/apps/backend/chatballs/webchat/loader.py @@ -1,6 +1,6 @@ # Публичный JS-лоадер виджета (SPEC-HUB-0003 §3, SPEC-HUB-0010 §7.1). Подключается # одним тегом: -# # 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) { diff --git a/apps/backend/chatballs/webchat/tests.py b/apps/backend/chatballs/webchat/tests.py index 5d8ca41..b9cd754 100644 --- a/apps/backend/chatballs/webchat/tests.py +++ b/apps/backend/chatballs/webchat/tests.py @@ -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", diff --git a/apps/backend/chatballs_backend/settings_base.py b/apps/backend/chatballs_backend/settings_base.py index 20e702c..d51fa16 100644 --- a/apps/backend/chatballs_backend/settings_base.py +++ b/apps/backend/chatballs_backend/settings_base.py @@ -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 ") +DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Chatballs ") # 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() diff --git a/apps/internal-ui/package.json b/apps/internal-ui/package.json index 51d9457..a1d28e3 100644 --- a/apps/internal-ui/package.json +++ b/apps/internal-ui/package.json @@ -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", diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 45bcf3e..c8e4934 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -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(null); diff --git a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx index 019165b..ed2f881 100644 --- a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx @@ -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"; diff --git a/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx b/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx index 0520c1a..673d1c6 100644 --- a/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx @@ -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"; diff --git a/apps/internal-ui/src/features/conversations/emailContent.test.ts b/apps/internal-ui/src/features/conversations/emailContent.test.ts index b797fcd..ef9054a 100644 --- a/apps/internal-ui/src/features/conversations/emailContent.test.ts +++ b/apps/internal-ui/src/features/conversations/emailContent.test.ts @@ -18,10 +18,10 @@ describe("splitQuotedEmail", () => { }); it("separates an inline localized reply header", () => { - const text = "Держись\nСр, 22 июля 2026 г. в 21:10, Foxray : > Старое письмо"; + const text = "Держись\nСр, 22 июля 2026 г. в 21:10, Foxray : > Старое письмо"; expect(splitQuotedEmail(text)).toEqual({ latest: "Держись", - quoted: "Ср, 22 июля 2026 г. в 21:10, Foxray : > Старое письмо", + quoted: "Ср, 22 июля 2026 г. в 21:10, Foxray : > Старое письмо", }); }); }); diff --git a/apps/internal-ui/src/features/conversations/useIncomingMessageSound.ts b/apps/internal-ui/src/features/conversations/useIncomingMessageSound.ts index 6a44dc2..b446e9c 100644 --- a/apps/internal-ui/src/features/conversations/useIncomingMessageSound.ts +++ b/apps/internal-ui/src/features/conversations/useIncomingMessageSound.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; -import { useAudioCue } from "@edevs/ui"; +import { useAudioCue } from "@chatballs/ui"; import type { ApiConversation } from "./model"; diff --git a/apps/internal-ui/src/features/integrations/model.test.ts b/apps/internal-ui/src/features/integrations/model.test.ts index b78a701..b625b5c 100644 --- a/apps/internal-ui/src/features/integrations/model.test.ts +++ b/apps/internal-ui/src/features/integrations/model.test.ts @@ -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( - ``, + expect(webWidgetSnippet("wgt_demo")).toBe( + ``, ); }); @@ -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( - ``, + expect(webWidgetSnippet("wgt_demo")).toBe( + ``, ); }); }); @@ -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("не домен"); }); }); diff --git a/apps/internal-ui/src/features/sales/SalesClientsPage.tsx b/apps/internal-ui/src/features/sales/SalesClientsPage.tsx index d550f02..3de7f6f 100644 --- a/apps/internal-ui/src/features/sales/SalesClientsPage.tsx +++ b/apps/internal-ui/src/features/sales/SalesClientsPage.tsx @@ -19,7 +19,7 @@ export function SalesClientsPage({ openClient }: { openClient: (id: number) => v ) : ( <> - + )} diff --git a/apps/internal-ui/src/features/sales/client-detail/model.ts b/apps/internal-ui/src/features/sales/client-detail/model.ts index 97ce716..37de8a6 100644 --- a/apps/internal-ui/src/features/sales/client-detail/model.ts +++ b/apps/internal-ui/src/features/sales/client-detail/model.ts @@ -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) }, diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx index db9382c..fbc24d4 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx +++ b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx @@ -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 (
diff --git a/apps/internal-ui/src/features/sales/clients/model.ts b/apps/internal-ui/src/features/sales/clients/model.ts index 67d024b..1367c3f 100644 --- a/apps/internal-ui/src/features/sales/clients/model.ts +++ b/apps/internal-ui/src/features/sales/clients/model.ts @@ -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; -export const productMap = { - FP: { name: "FirePage", color: "#0958d9", bg: "#e6f4ff" }, - FX: { name: "Foxray", color: "#722ed1", bg: "#f9f0ff" }, -} satisfies Record; +// Цвет продукта — детерминированно по коду (одна палитра с аватарами). +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(); + 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", }; diff --git a/apps/internal-ui/src/features/sales/clients/useSalesClients.ts b/apps/internal-ui/src/features/sales/clients/useSalesClients.ts index 26ffc57..ff96d4a 100644 --- a/apps/internal-ui/src/features/sales/clients/useSalesClients.ts +++ b/apps/internal-ui/src/features/sales/clients/useSalesClients.ts @@ -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) { diff --git a/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx b/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx index 43670df..cf1b69a 100644 --- a/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx +++ b/apps/internal-ui/src/features/support-portals/PortalCreateDialog.tsx @@ -71,7 +71,7 @@ export function PortalCreateDialog({

Портал — это публичный центр помощи с инструкциями и ответами для клиентов.

- +