import pytest
from fastapi.testclient import TestClient
from httpx import Response # Используем Response из httpx
from unittest.mock import patch, MagicMock
import os
import requests # <-- Добавлен импорт
# Мокирование load_dotenv (остается)
patcher_dotenv_app = patch('dotenv.load_dotenv', return_value=True)
patcher_dotenv_app.start()
# Импортируем app и get_search_session ПОСЛЕ старта патчера dotenv
from backend.app import app, get_search_session
# Фикстура для мокированной сессии (теперь будет использоваться в dependency_overrides)
@pytest.fixture
def mock_search_session_fixture():
mock_session_instance = MagicMock(spec=requests.Session)
# Настраиваем мок для POST запроса на /search
mock_response_search_ok = MagicMock(spec=Response)
mock_response_search_ok.status_code = 200
mock_response_search_ok.json.return_value = {
"hits": [
{"id": "test.txt", "_formatted": {"id": "test.txt", "content": "Это тест"}},
{"id": "another.pdf", "_formatted": {"id": "another.pdf", "content": "Еще один тестовый файл"}}
], "estimatedTotalHits": 2 # Добавим обязательные поля для Meili v1+
}
mock_response_search_ok.raise_for_status.return_value = None # Успешный запрос не вызывает исключений
# Настраиваем мок для GET запроса на /health
mock_response_health_ok = MagicMock(spec=Response)
mock_response_health_ok.status_code = 200
mock_response_health_ok.json.return_value = {"status": "available"}
mock_response_health_ok.raise_for_status.return_value = None
# Мок для ошибки Meili при поиске
mock_response_search_err = MagicMock(spec=Response)
mock_response_search_err.status_code = 503 # Или 500, в зависимости от ошибки Meili
http_error = requests.exceptions.RequestException("Simulated Meili Connection Error")
mock_response_search_err.raise_for_status.side_effect = http_error
# Мок для ошибки Meili при health check
mock_response_health_err = MagicMock(spec=Response)
mock_response_health_err.status_code = 503
health_http_error = requests.exceptions.RequestException("Simulated Meili Health Error")
mock_response_health_err.raise_for_status.side_effect = health_http_error
# Используем side_effect для разных URL и методов
def side_effect_post(*args, **kwargs):
url = args[0]
if 'search' in url:
query = kwargs.get('json', {}).get('q')
if query == "ошибка_сети": # Имитируем ошибку сети при поиске
# Имитируем ошибку RequestException, которая должна привести к 503 в app.py
raise requests.exceptions.RequestException("Simulated network error during search")
else: # Успешный поиск
return mock_response_search_ok
return MagicMock(status_code=404) # Поведение по умолчанию
def side_effect_get(*args, **kwargs):
url = args[0]
if 'health' in url:
# Имитируем ситуацию, когда health check падает с ошибкой сети
# Чтобы тест test_health_check проверял статус 'недоступен'
# raise requests.exceptions.RequestException("Simulated network error during health")
# ИЛИ Имитируем успешный ответ, чтобы тест проверял статус 'доступен'
return mock_response_health_ok # <--- ИЗМЕНИ ЭТУ СТРОКУ, если хочешь проверить другой сценарий health
return MagicMock(status_code=404)
mock_session_instance.post.side_effect = side_effect_post
mock_session_instance.get.side_effect = side_effect_get
# Возвращаем функцию, которая будет вызвана FastAPI вместо get_search_session
def override_get_search_session():
return mock_session_instance
yield override_get_search_session, mock_session_instance # Возвращаем и функцию, и сам мок для проверок вызовов
# Останавливаем патчер dotenv после тестов модуля
patcher_dotenv_app.stop()
# Фикстура для тестового клиента с переопределенной зависимостью
@pytest.fixture(scope="module")
def client(mock_search_session_fixture) -> TestClient:
override_func, _ = mock_search_session_fixture
app.dependency_overrides[get_search_session] = override_func
yield TestClient(app)
# Очищаем переопределение после тестов
app.dependency_overrides.clear()
# --- ТЕСТЫ ---
def test_search_success(client: TestClient, mock_search_session_fixture):
"""Тестирует успешный поиск."""
_, mock_session = mock_search_session_fixture
response = client.get("/search?q=тест")
assert response.status_code == 200
data = response.json()
assert "results" in data
assert len(data["results"]) == 2
assert data["results"][0]["id"] == "test.txt"
# Проверяем, что был вызван POST к MeiliSearch
mock_session.post.assert_called_once()
def test_search_empty_query(client: TestClient):
"""Тестирует поиск с пустым запросом (FastAPI вернет 422)."""
# Теперь не должно быть сетевого вызова, FastAPI вернет ошибку валидации
response = client.get("/search?q=")
assert response.status_code == 422 # Ошибка валидации FastAPI
def test_search_meili_error(client: TestClient, mock_search_session_fixture):
"""Тестирует обработку ошибки сети при обращении к Meilisearch."""
# Используем 'ошибка_сети', чтобы вызвать RequestException в моке
response = client.get("/search?q=ошибка_сети")
# Ожидаем 503, так как app.py ловит RequestException
assert response.status_code == 503
assert "Сервис поиска временно недоступен" in response.json()["detail"]
def test_get_file_not_found(client: TestClient):
"""Тестирует запрос несуществующего файла."""
# Мокируем os.path.exists, чтобы имитировать отсутствие файла
with patch("backend.app.os.path.exists", return_value=False):
response = client.get("/files/nonexistent.txt")
assert response.status_code == 404
assert response.json()["detail"] == "Файл не найден"
def test_get_file_invalid_name_slash(client: TestClient):
"""Тестирует запрос файла с '/' в имени."""
# Эта проверка должна срабатывать в FastAPI
response = client.get("/files/subdir/secret.txt")
assert response.status_code == 400
assert response.json()["detail"] == "Некорректное имя файла"
def test_get_file_invalid_name_dotdot(client: TestClient):
"""Тестирует запрос файла с '..' в имени."""
# Попробуем этот запрос, он все еще может нормализоваться клиентом,
# но если нет - проверка FastAPI должна вернуть 400.
# Если клиент нормализует, ожидаем 404 или 403 в зависимости от дальнейшей логики.
response = client.get("/files/../secret.txt")
# Ожидаем 400 от FastAPI проверки, если она сработает
# или 404 если имя нормализовалось и файл не найден (без мока os.path)
# или 403 если имя нормализовалось и вышло за пределы корня (этот тест не проверяет)
assert response.status_code in [400, 404] # Допускаем оба варианта из-за неопределенности нормализации
def test_health_check_meili_ok(client: TestClient, mock_search_session_fixture):
"""Тестирует эндпоинт /health, когда Meilisearch доступен."""
override_func, mock_session = mock_search_session_fixture
# Убедимся, что мок GET возвращает успешный ответ
mock_response_health_ok = MagicMock(spec=Response)
mock_response_health_ok.status_code = 200
mock_response_health_ok.json.return_value = {"status": "available"}
mock_response_health_ok.raise_for_status.return_value = None
mock_session.get.side_effect = lambda *args, **kwargs: mock_response_health_ok if 'health' in args[0] else MagicMock(status_code=404)
# Переопределяем зависимость для этого теста
app.dependency_overrides[get_search_session] = override_func
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["meilisearch_status"] == "доступен" # Ожидаем "доступен"
mock_session.get.assert_called_once()
app.dependency_overrides.clear() # Очищаем после теста
def test_health_check_meili_fail(client: TestClient, mock_search_session_fixture):
"""Тестирует эндпоинт /health, когда Meilisearch недоступен."""
override_func, mock_session = mock_search_session_fixture
# Убедимся, что мок GET вызывает ошибку сети
mock_session.get.side_effect = requests.exceptions.RequestException("Simulated health check network error")
# Переопределяем зависимость для этого теста
app.dependency_overrides[get_search_session] = override_func
response = client.get("/health")
assert response.status_code == 200 # Сам эндпоинт /health должен отработать
data = response.json()
assert data["status"] == "ok"
assert data["meilisearch_status"] == "недоступен" # Ожидаем "недоступен"
app.dependency_overrides.clear() # Очищаем после теста