Corp/backend/tests/api/v1/test_search.py
lizerk 2768d2f1b3
Some checks failed
Deploy production / deploy (push) Failing after 14m49s
Restore reliable chat realtime and media playback
2026-09-10 20:26:58 +03:00

623 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend/tests/api/v1/test_search.py
"""
Тесты для глобального поиска через django-watson.
"""
import itertools
from datetime import timedelta
import pytest
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.management import call_command
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APIClient
from schedule.models import Calendar, CalendarRelation, Event
from api.v1.search.views import _can_view_search_result
from commerce.models import Product
from communications.models import Chat, Message
from documents.models import Document
from employees.models import Department, Employee, Position
from feed.models import Post
from notifications.models import Notification
from requests_app.models import Request
from tasks.models import TaskBoard, TaskBoardAccessScope
_phone_counter = itertools.count(100_000_000)
_email_counter = itertools.count(1)
@pytest.fixture
def make_user(db):
"""Фабрика пользователей."""
User = get_user_model()
def _make_user(email=None, password="pass12345", **extra):
if email is None:
email = f"user{next(_email_counter)}@example.com"
phone = extra.pop("phone_number", None) or f"+79{next(_phone_counter):09d}"
user = User.objects.create_user(
email=email, phone_number=phone, send_activation_email=False, **extra
)
if password:
user.set_password(password)
user.save()
return user
return _make_user
@pytest.fixture
def regular_user(make_user):
"""Обычный пользователь без спецправ."""
return make_user(
email="regular@example.com",
first_name="Регуляр",
last_name="Пользователь",
is_active=True,
)
@pytest.fixture
def api_client(regular_user):
"""Авторизованный DRF-клиент."""
client = APIClient()
client.force_authenticate(user=regular_user)
return client
@pytest.fixture
def sample_data(db):
"""Создаёт тестовые данные для поиска."""
# Отделы
dept_it = Department.objects.create(
name="IT отдел", description="Информационные технологии"
)
dept_hr = Department.objects.create(
name="HR отдел", description="Управление персоналом"
)
# Должности
pos_dev = Position.objects.create(
name="Разработчик", description="Python/Django разработчик"
)
pos_manager = Position.objects.create(name="Менеджер", description="HR менеджер")
# Сотрудники
emp1 = Employee.objects.create(
email="ivanov@test.com",
first_name="Иван",
last_name="Иванов",
patronymic="Иванович",
position=pos_dev,
phone_number="+79001234567",
)
emp1.departments_links.create(department=dept_it)
emp2 = Employee.objects.create(
email="petrov@test.com",
first_name="Пётр",
last_name="Петров",
patronymic="Петрович",
position=pos_manager,
phone_number="+79007654321",
)
emp2.departments_links.create(department=dept_hr)
# Посты
post1 = Post.objects.create(
author=emp1,
title="Новые технологии в разработке",
body="Сегодня мы внедрили новый фреймворк Django для нашего проекта",
)
post2 = Post.objects.create(
author=emp2,
title="Вакансии в HR отделе",
body="Требуется менеджер по подбору персонала",
)
# Заявления
req1 = Request.objects.create(
employee=emp1,
title="Заявка на отпуск",
comment="Прошу предоставить отпуск с 1 по 14 февраля",
type="vacation",
status="pending",
)
return {
"departments": [dept_it, dept_hr],
"employees": [emp1, emp2],
"posts": [post1, post2],
"requests": [req1],
}
@pytest.mark.django_db
class TestSearchAPI:
"""Тесты для API /api/v1/search/"""
def test_search_requires_authentication(self, client):
"""Поиск требует аутентификации."""
response = client.get("/api/v1/search/?q=test")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_search_empty_query(self, api_client):
"""Пустой запрос возвращает пустые результаты."""
response = api_client.get("/api/v1/search/?q=")
assert response.status_code == status.HTTP_200_OK
assert response.data["query"] == ""
assert response.data["total"] == 0
assert len(response.data["results"]) == 0
def test_search_no_results(self, api_client, sample_data):
"""Поиск без результатов."""
# Пересоздаём индексы watson для тестовых данных
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=несуществующийтекст12345")
assert response.status_code == status.HTTP_200_OK
assert response.data["total"] == 0
def test_search_finds_employee_by_name(self, api_client, sample_data):
"""Поиск находит сотрудника по имени."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=Иванов")
assert response.status_code == status.HTTP_200_OK
assert response.data["total"] > 0
assert "employee" in response.data["counts"]
# Проверяем, что в результатах есть сотрудник
employees = [
r for r in response.data["results"] if r["model_name"] == "employee"
]
assert len(employees) > 0
assert "Иванов" in employees[0]["title"]
def test_search_finds_employee_by_email(self, api_client, sample_data):
"""Поиск находит сотрудника по email."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=ivanov@test.com")
assert response.status_code == status.HTTP_200_OK
employees = [
r for r in response.data["results"] if r["model_name"] == "employee"
]
assert len(employees) > 0
assert "ivanov@test.com" in employees[0]["meta"]["email"].lower()
def test_search_omits_employee_hidden_from_ui_without_revealing_count(
self,
api_client,
sample_data,
):
employee = sample_data["employees"][0]
employee.is_hidden_in_ui = True
employee.save(update_fields=["is_hidden_in_ui"])
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=ivanov@test.com")
assert response.status_code == status.HTTP_200_OK
assert not [
result
for result in response.data["results"]
if result["model_name"] == "employee"
]
assert response.data["counts"].get("employee", 0) == 0
assert response.data["hidden_counts"].get("employee", 0) == 0
@pytest.mark.parametrize("query", ["IT", "it", "It"])
def test_search_finds_department(self, api_client, sample_data, query):
"""Поиск находит отдел."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/", {"q": query})
assert response.status_code == status.HTTP_200_OK
departments = [
r for r in response.data["results"] if r["model_name"] == "department"
]
assert len(departments) > 0
assert "IT" in departments[0]["title"]
def test_stop_word_abbreviation_matches_title_tokens_only(self, api_client):
from django.db import connection
if connection.vendor != "postgresql":
pytest.skip("The narrow fallback is specific to PostgreSQL stop words")
match = Department.objects.create(name="Отдел (IT)")
Department.objects.create(name="Security")
Department.objects.create(name="Сервис", description="IT support")
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/", {"q": "it", "type": "department"})
assert response.status_code == status.HTTP_200_OK
assert [result["object_id"] for result in response.data["results"]] == [
match.pk
]
assert response.data["counts"]["department"] == 1
def test_abbreviation_fallback_preserves_document_permissions(
self, api_client, regular_user, make_user
):
owner = make_user(email="it-document-owner@example.com")
document = Document.objects.create(
title="IT",
uploaded_by=owner,
sent_to_all=False,
)
call_command("buildwatson", verbosity=0)
hidden_response = api_client.get("/api/v1/search/", {"q": "it"})
assert hidden_response.status_code == status.HTTP_200_OK
hidden_documents = [
result
for result in hidden_response.data["results"]
if result["model_name"] == "document"
]
assert not hidden_documents
assert hidden_response.data["counts"].get("document", 0) == 0
document.recipients.add(regular_user)
visible_response = api_client.get("/api/v1/search/", {"q": "it"})
assert visible_response.status_code == status.HTTP_200_OK
visible_documents = [
result
for result in visible_response.data["results"]
if result["model_name"] == "document"
]
assert [result["object_id"] for result in visible_documents] == [document.pk]
assert visible_response.data["counts"]["document"] == 1
def test_search_finds_post(self, api_client, sample_data):
"""Поиск находит пост."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=Django")
assert response.status_code == status.HTTP_200_OK
posts = [r for r in response.data["results"] if r["model_name"] == "post"]
assert len(posts) > 0
assert "Django" in posts[0]["description"] or "Django" in posts[0]["title"]
def test_search_finds_request(self, api_client, sample_data):
"""Поиск находит заявление."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
# Авторизуемся как автор заявления
emp = sample_data["employees"][0]
api_client.force_authenticate(user=emp)
response = api_client.get("/api/v1/search/?q=отпуск")
assert response.status_code == status.HTTP_200_OK
requests = [r for r in response.data["results"] if r["model_name"] == "request"]
assert len(requests) > 0
assert "отпуск" in requests[0]["title"].lower()
def test_search_limit_parameter(self, api_client, sample_data):
"""Параметр limit ограничивает количество результатов на тип."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
# Создадим больше 2 постов
emp = sample_data["employees"][0]
for i in range(5):
Post.objects.create(
author=emp, title=f"Тестовый пост {i}", body="Django разработка"
)
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=Django&limit=2")
assert response.status_code == status.HTTP_200_OK
posts = [r for r in response.data["results"] if r["model_name"] == "post"]
assert len(posts) <= 2
def test_search_supports_category_pagination(self, api_client, sample_data):
"""Следующая страница возвращает новые записи только выбранного типа."""
from django.core.management import call_command
emp = sample_data["employees"][0]
for i in range(5):
Post.objects.create(
author=emp,
title=f"Пагинация поиска {i}",
body="Маркер пагинации поиска",
)
call_command("buildwatson", verbosity=0)
first = api_client.get(
"/api/v1/search/?q=Маркер пагинации поиска&limit=2&type=post"
)
second = api_client.get(
"/api/v1/search/?q=Маркер пагинации поиска&limit=2&offset=2&type=post"
)
assert first.status_code == status.HTTP_200_OK
assert second.status_code == status.HTTP_200_OK
assert len(first.data["results"]) == 2
assert len(second.data["results"]) == 2
assert all(result["model_name"] == "post" for result in second.data["results"])
assert {result["object_id"] for result in first.data["results"]}.isdisjoint(
{result["object_id"] for result in second.data["results"]}
)
assert second.data["counts"]["post"] == 5
def test_search_response_structure(self, api_client, sample_data):
"""Проверка структуры ответа API."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=Иванов")
assert response.status_code == status.HTTP_200_OK
# Проверяем ключи верхнего уровня
assert "query" in response.data
assert "results" in response.data
assert "counts" in response.data
assert "hidden_counts" in response.data
assert "total" in response.data
assert "hidden_total" in response.data
# Проверяем структуру результата
if len(response.data["results"]) > 0:
result = response.data["results"][0]
assert "model_name" in result
assert "object_id" in result
assert "title" in result
assert "url" not in result
def test_search_request_permissions(self, api_client, sample_data):
"""Пользователи видят только свои заявления."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
emp1 = sample_data["employees"][0]
emp2 = sample_data["employees"][1]
# Создаём заявление от emp1
Request.objects.create(
employee=emp1,
title="Личная заявка Иванова",
comment="Конфиденциально",
type="vacation",
status="pending",
)
call_command("buildwatson", verbosity=0)
# Авторизуемся как emp2
api_client.force_authenticate(user=emp2)
response = api_client.get("/api/v1/search/?q=Иванова")
assert response.status_code == status.HTTP_200_OK
# emp2 не должен видеть заявку emp1
requests = [r for r in response.data["results"] if r["model_name"] == "request"]
for req in requests:
# Если есть заявления в результатах, они не должны быть от emp1
if "Личная заявка" in req["title"]:
pytest.fail("Пользователь видит чужое заявление")
def test_search_does_not_bypass_domain_object_permissions(
self, api_client, regular_user, make_user
):
owner = make_user(email="search-owner@example.com")
marker = "ClassifiedSearchMarker"
chat = Chat.objects.create(
type="private",
name=marker,
created_by=owner,
)
chat.participants.add(owner)
Message.objects.create(chat=chat, author=owner, content=marker)
document = Document.objects.create(
title=marker,
uploaded_by=owner,
sent_to_all=False,
)
calendar = Calendar.objects.create(
name="Private search calendar",
slug=f"private-search-{owner.id}",
)
Event.objects.create(
calendar=calendar,
creator=owner,
title=marker,
start=timezone.now(),
end=timezone.now() + timedelta(hours=1),
)
call_command("buildwatson", verbosity=0)
hidden_response = api_client.get(f"/api/v1/search/?q={marker}")
hidden_models = {item["model_name"] for item in hidden_response.data["results"]}
assert not _can_view_search_result(regular_user, document, "document")
assert hidden_models.isdisjoint({"chat", "message", "schedule_event"})
assert hidden_response.data["hidden_counts"]["chat"] == 1
assert hidden_response.data["hidden_counts"]["message"] == 1
assert hidden_response.data["hidden_counts"]["document"] == 1
assert hidden_response.data["hidden_counts"]["schedule_event"] == 1
assert hidden_response.data["hidden_total"] >= 4
chat.participants.add(regular_user)
document.recipients.add(regular_user)
CalendarRelation.objects.create(
calendar=calendar,
content_type=ContentType.objects.get_for_model(get_user_model()),
object_id=regular_user.id,
distinction="viewer",
)
visible_response = api_client.get(f"/api/v1/search/?q={marker}")
visible_models = {
item["model_name"] for item in visible_response.data["results"]
}
assert _can_view_search_result(regular_user, document, "document")
assert {"chat", "message", "schedule_event"} <= visible_models
assert visible_response.data["hidden_counts"].get("chat", 0) == 0
assert visible_response.data["hidden_counts"].get("message", 0) == 0
assert visible_response.data["hidden_counts"].get("document", 0) == 0
assert visible_response.data["hidden_counts"].get("schedule_event", 0) == 0
def test_search_returns_employee_avatar(self, api_client, sample_data):
"""Результат сотрудника содержит фото для карточки поиска."""
employee = sample_data["employees"][0]
employee.avatar = "users/avatars/search-test.jpg"
employee.save(update_fields=["avatar"])
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=Иванов")
assert response.status_code == status.HTTP_200_OK
employees = [
item
for item in response.data["results"]
if item["model_name"] == "employee"
]
assert employees
assert employees[0]["image"].endswith("/users/avatars/search-test.jpg")
@pytest.mark.parametrize("model_type", ["", "product"])
def test_search_hides_deferred_products(self, api_client, model_type):
"""Отложенный модуль не раскрывается через поиск и его счётчики."""
Product.objects.create(
sku="SEARCH-SKU-001",
name="Уникальный поисковый товар",
barcode="4600000000001",
)
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/", {"q": "товар", "type": model_type})
assert response.status_code == status.HTTP_200_OK
products = [
item for item in response.data["results"] if item["model_name"] == "product"
]
assert products == []
assert "product" not in response.data["counts"]
assert "product" not in response.data["hidden_counts"]
assert response.data["total"] == 0
def test_search_counts_inaccessible_task_boards(
self, api_client, regular_user, make_user
):
"""Закрытые доски считаются, но не раскрываются в результатах."""
owner = make_user(email="search-board-owner@example.com")
board = TaskBoard.objects.create(
name="Секретная поисковая доска",
created_by=owner,
access_scope=TaskBoardAccessScope.PRIVATE,
)
call_command("buildwatson", verbosity=0)
hidden_response = api_client.get("/api/v1/search/?q=поисковая")
assert hidden_response.status_code == status.HTTP_200_OK
assert hidden_response.data["hidden_counts"]["task_board"] == 1
assert not any(
item["model_name"] == "task_board"
for item in hidden_response.data["results"]
)
board.access_scope = TaskBoardAccessScope.ALL
board.save(update_fields=["access_scope"])
visible_response = api_client.get("/api/v1/search/?q=поисковая")
assert visible_response.status_code == status.HTTP_200_OK
assert any(
item["model_name"] == "task_board" and item["object_id"] == board.id
for item in visible_response.data["results"]
)
def test_search_cyrillic_query(self, api_client, sample_data):
"""Поиск по кириллице работает корректно."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=технологии")
assert response.status_code == status.HTTP_200_OK
# Должны найти пост "Новые технологии в разработке"
posts = [r for r in response.data["results"] if r["model_name"] == "post"]
assert len(posts) > 0
def test_search_formats_notification_v2_without_500(self, api_client, regular_user):
"""Поиск по уведомлениям v2 не падает на legacy-ожиданиях формата."""
from django.core.management import call_command
Notification.objects.create(
recipient=regular_user,
verb="system_notice",
description="Это обычный текст уведомления для поиска",
data={"title": "Важное уведомление"},
action_url="/documents/5/",
)
call_command("buildwatson", verbosity=0)
response = api_client.get("/api/v1/search/?q=обычный")
assert response.status_code == status.HTTP_200_OK
notifications = [
r for r in response.data["results"] if r["model_name"] == "notification"
]
assert len(notifications) > 0
assert notifications[0]["title"] == "Важное уведомление"
assert "обычный текст уведомления" in notifications[0]["description"].lower()
assert "url" not in notifications[0]
@pytest.mark.django_db
class TestSearchIndexing:
"""Тесты индексации watson."""
def test_watson_index_auto_updates(self, api_client, sample_data):
"""Индекс watson обновляется автоматически при изменении данных."""
from django.core.management import call_command
call_command("buildwatson", verbosity=0)
# Создаём нового сотрудника
Employee.objects.create(
email="sidorov@test.com",
first_name="Сидор",
last_name="Сидоров",
patronymic="Сидорович",
)
# watson должен автоматически добавить в индекс
response = api_client.get("/api/v1/search/?q=Сидоров")
assert response.status_code == status.HTTP_200_OK
employees = [
r for r in response.data["results"] if r["model_name"] == "employee"
]
assert len(employees) > 0
assert "Сидоров" in employees[0]["title"]