Files
chatballs/apps/backend/hub_platform/testing.py
T
AndreyandClaude Fable 5 774eb3215a ✨ feat(agents)!: агент и канал — одна сущность (этап 3 пивота, ADR-HUB-0041 §4)
- новый агрегированный API /api/v1/agents/: мастер одного шага (имя+группа,
  код из имени), объединённый PATCH канала и AI-конфигурации, удаление с
  блокерами, активация AI, привязка/отвязка подключений, тест-чат;
- data-миграция ai/0015: DRAFT-агент каждому каналу без агента;
- UI: разделы «Каналы» и «AI-агенты» слиты в «Агенты» (список + карточка-центр
  администрирования); legacy-URL редиректят; форма интеграции выбирает агента;
- чистка: features/channels и features/ai/{list,create,detail} удалены,
  битые @import styles.css (этапы 1-2) починены — прод-сборка снова собирается;
- дизайн-базлайн v2 владельца добавлен в design/baseline/Chatbolls Baseline v2/.

Backend 582 теста OK (докер, миграции с нуля), frontend tsc + vitest 69 OK, build OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 19:14:52 +03:00

108 lines
3.8 KiB
Python

from __future__ import annotations
from rest_framework.test import APIClient
from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership
from hub_platform.tenancy.context import TenantActorKind, TenantContext
def tenant_context_for(
user: HumanUser,
organization: Organization | None = None,
) -> TenantContext:
memberships = OrganizationMembership.objects.select_related("organization", "user").filter(
user=user,
blocked_at__isnull=True,
)
if organization is not None:
memberships = memberships.filter(organization=organization)
return TenantContext.for_membership(memberships.get())
def system_tenant_context(organization: Organization) -> TenantContext:
return TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
class TenantAPIClient(APIClient):
"""Test client that turns legacy test literals into the C03 tenant route.
Production URL resolution is untouched. New tenancy tests call canonical URLs
directly and assert that the old runtime routes return 404.
"""
tenant_namespaces = {
"access-profiles",
"agents",
"ai",
"calls",
"channels",
"company",
"conversations",
"employees",
"integrations",
"notifications",
"support",
}
public_prefixes = (
"/api/v1/ai/files/",
"/api/v1/calls/access/",
"/api/v1/calls/invites/",
"/api/v1/support/sessions/",
"/api/v1/help/",
)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._tenant_user = None
self.organization_public_id = None
def force_authenticate(self, user=None, token=None) -> None:
self._tenant_user = user
if user is not None:
# Tenant middleware runs before DRF's force-auth hook, so test
# requests also need Django's authenticated session available.
super().force_login(user)
super().force_authenticate(user=user, token=token)
def login(self, **credentials) -> bool:
authenticated = super().login(**credentials)
if authenticated:
email = credentials.get("email") or credentials.get("username")
self._tenant_user = HumanUser.objects.filter(email=email).first()
return authenticated
def force_login(self, user, backend=None) -> None:
self._tenant_user = user
super().force_login(user, backend=backend)
def logout(self) -> None:
self._tenant_user = None
super().logout()
def set_tenant(self, organization) -> None:
self.organization_public_id = organization.public_id
def generic(self, method, path, data="", content_type="application/octet-stream", secure=False, **extra):
path = self._organization_path(path)
return super().generic(method, path, data, content_type, secure, **extra)
def _organization_path(self, path: str) -> str:
if not path.startswith("/api/v1/") or path.startswith("/api/v1/organizations/"):
return path
if any(path.startswith(prefix) for prefix in self.public_prefixes):
return path
namespace = path[len("/api/v1/") :].split("/", 1)[0]
if namespace not in self.tenant_namespaces:
return path
public_id = self.organization_public_id
if public_id is None and self._tenant_user is not None:
values = list(
self._tenant_user.memberships.filter(blocked_at__isnull=True).values_list(
"organization__public_id", flat=True
)[:2]
)
public_id = values[0] if len(values) == 1 else None
if public_id is None:
return path
return f"/api/v1/organizations/{public_id}{path[len('/api/v1') :]}"