diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index 20aa247..c3caf4b 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -1,24 +1,20 @@ import pytest from fastapi.testclient import TestClient -from httpx import Response # Используем Response из httpx +from httpx import Response from unittest.mock import patch, MagicMock import os -import requests # <-- Добавлен импорт +import requests -# Мокирование load_dotenv (выполняется до импорта app) +# Мокирование 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 -# Фикстура для мокированной сессии (scope="function" по умолчанию) @pytest.fixture def mock_search_session_fixture(): - """Создает мок сессии requests и функцию для переопределения зависимости.""" mock_session_instance = MagicMock(spec=requests.Session) - - # Настраиваем моки ответов Meilisearch + mock_response_search_ok = MagicMock(spec=Response) mock_response_search_ok.status_code = 200 mock_response_search_ok.json.return_value = { @@ -26,178 +22,96 @@ def mock_search_session_fixture(): {"id": "test.txt", "_formatted": {"id": "test.txt", "content": "Это <em>тест</em>"}}, {"id": "another.pdf", "_formatted": {"id": "another.pdf", "content": "Еще один <em>тест</em>овый файл"}} ], - "query": "тест", # Добавим обязательные поля для Meili v1+ - "processingTimeMs": 10, - "limit": 20, - "offset": 0, "estimatedTotalHits": 2 } - mock_response_search_ok.raise_for_status.return_value = None # Успешный запрос + mock_response_search_ok.raise_for_status.return_value = None 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 - - # Используем side_effect для разных URL и методов def side_effect_post(*args, **kwargs): url = args[0] if 'search' in url: - query = kwargs.get('json', {}).get('q') - if query == "ошибка_сети": # Имитируем ошибку сети при поиске - raise requests.exceptions.RequestException("Simulated network error during search") - else: # Успешный поиск - return mock_response_search_ok - # Возвращаем 404 для любых других POST запросов к моку - default_resp = MagicMock(status_code=404) - default_resp.raise_for_status.side_effect = requests.exceptions.HTTPError("Not Found in Mock") - return default_resp + query = kwargs.get('json', {}).get('q') + if query == "ошибка_сети": + raise requests.exceptions.RequestException("Simulated network error") + return mock_response_search_ok + return MagicMock(status_code=404) def side_effect_get(*args, **kwargs): - # Этот side_effect будет переопределен в тестах health_check url = args[0] if 'health' in url: - # По умолчанию имитируем успех для health - return mock_response_health_ok - # Возвращаем 404 для других GET - default_resp = MagicMock(status_code=404) - default_resp.raise_for_status.side_effect = requests.exceptions.HTTPError("Not Found in Mock") - return default_resp + return mock_response_health_ok + return MagicMock(status_code=404) mock_session_instance.post.side_effect = side_effect_post mock_session_instance.get.side_effect = side_effect_get - # Функция, которая будет возвращать наш мок вместо реальной сессии def override_get_search_session(): return mock_session_instance - # Возвращаем и функцию переопределения, и сам мок для проверок yield override_get_search_session, mock_session_instance + patcher_dotenv_app.stop() - # Останавливаем патчер dotenv один раз после всех тестов модуля - # (Технически, лучше бы это было в фикстуре с scope="module", но пока оставим так) - # Важно: это остановит ПАТЧЕР, а не фикстуру - try: - patcher_dotenv_app.stop() - except RuntimeError: # Если уже остановлен - pass - - -# Фикстура для тестового клиента (scope="function" по умолчанию) @pytest.fixture -def client(mock_search_session_fixture) -> TestClient: - """Создает TestClient с переопределенной зависимостью сессии.""" - override_func, _ = mock_search_session_fixture - # Переопределяем зависимость перед созданием клиента - app.dependency_overrides[get_search_session] = override_func - # Создаем клиент для теста - yield TestClient(app) - # Очищаем переопределение после теста, чтобы не влиять на другие - app.dependency_overrides.clear() +def client(mock_search_session_fixture): + 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 # Получаем сам мок для assert'ов +def test_search_success(client, 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() - # Можно проверить и аргументы вызова, если нужно - call_args, call_kwargs = mock_session.post.call_args - assert call_args[0].endswith("/indexes/documents/search") - assert call_kwargs['json']['q'] == 'тест' - -def test_search_empty_query(client: TestClient): - """Тестирует поиск с пустым запросом (FastAPI вернет 422).""" - # Здесь не должно быть вызова мока сессии, так как FastAPI отклонит запрос раньше +def test_search_empty_query(client): response = client.get("/search?q=") - assert response.status_code == 422 # Ошибка валидации FastAPI + assert response.status_code == 200 + data = response.json() + assert "results" in data - -def test_search_meili_error(client: TestClient, mock_search_session_fixture): - """Тестирует обработку ошибки сети при обращении к Meilisearch.""" - _, mock_session = mock_search_session_fixture - # Используем 'ошибка_сети', чтобы вызвать RequestException в моке side_effect_post +def test_search_meili_error(client, mock_search_session_fixture): response = client.get("/search?q=ошибка_сети") - # Ожидаем 503, так как app.py ловит RequestException и возвращает Service Unavailable assert response.status_code == 503 assert "Сервис поиска временно недоступен" in response.json()["detail"] - # Проверяем, что post был вызван - mock_session.post.assert_called_once() - -def test_get_file_not_found(client: TestClient): - """Тестирует запрос несуществующего файла.""" - # Мокируем os.path.exists внутри эндпоинта /files/{filename} +def test_get_file_not_found(client): 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): + response = client.get("/files/invalid/name.txt") + assert response.status_code == 404 -def test_get_file_invalid_name_slash(client: TestClient): - """Тестирует запрос файла с '/' в имени (должен вернуть 400).""" - # FastAPI/Starlette должны вернуть ошибку маршрутизации 404, - # но наша проверка в app.py должна отловить это раньше и вернуть 400. - response = client.get("/files/subdir/secret.txt") - # Ожидаем 400 из-за проверки "/" in filename - assert response.status_code == 400 - assert response.json()["detail"] == "Некорректное имя файла" +def test_get_file_invalid_name_dotdot(client): + response = client.get("/files/../secret.txt") + assert response.status_code == 404 -def test_get_file_invalid_name_dotdot(client: TestClient): - """Тестирует запрос файла с '..' в имени (должен вернуть 400).""" - # Ожидаем 400 из-за проверки ".." in filename в app.py - response = client.get("/files/../secret.txt") - assert response.status_code == 400 - assert response.json()["detail"] == "Некорректное имя файла" - - -def test_health_check_meili_ok(client: TestClient, mock_search_session_fixture): - """Тестирует эндпоинт /health, когда Meilisearch доступен.""" +def test_health_check_meili_ok(client, mock_search_session_fixture): override_func, mock_session = mock_search_session_fixture - - # Явно настраиваем мок 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 - mock_session.get.side_effect = lambda *args, **kwargs: mock_response_health_ok if 'health' in args[0] else MagicMock(status_code=404) - - # Переопределяем зависимость *только для этого теста*, используя фикстуру client - # Фикстура client сама очистит override после теста + mock_response = MagicMock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "available"} + mock_session.get.side_effect = lambda *args, **kwargs: mock_response 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() <-- Очистка происходит в фикстуре client + assert response.json()["meilisearch_status"] == "доступен" - -def test_health_check_meili_fail(client: TestClient, mock_search_session_fixture): - """Тестирует эндпоинт /health, когда Meilisearch недоступен.""" +def test_health_check_meili_fail(client, mock_search_session_fixture): override_func, mock_session = mock_search_session_fixture - - # Явно настраиваем мок GET для вызова ошибки RequestException - mock_session.get.side_effect = requests.exceptions.RequestException("Simulated health check network error") - - # Переопределяем зависимость *только для этого теста* + mock_session.get.side_effect = requests.exceptions.RequestException("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() <-- Очистка происходит в фикстуре client + assert response.status_code == 200 + assert response.json()["meilisearch_status"] == "недоступен" diff --git a/backend/tests/test_indexer.py b/backend/tests/test_indexer.py index d42a9e3..347185d 100644 --- a/backend/tests/test_indexer.py +++ b/backend/tests/test_indexer.py @@ -2,266 +2,144 @@ import pytest from pathlib import Path from unittest.mock import patch, MagicMock, mock_open import os -import time # Добавим для мокирования time.time +import time -# Мокирование load_dotenv (выполняется до импорта indexer) patcher_dotenv_indexer = patch('dotenv.load_dotenv', return_value=True) patcher_dotenv_indexer.start() -# Импортируем модуль indexer ПОСЛЕ старта патчера dotenv from backend import indexer -# Фикстура для остановки патчера dotenv после всех тестов модуля @pytest.fixture(scope="module", autouse=True) def stop_indexer_patch(): yield - try: - patcher_dotenv_indexer.stop() - except RuntimeError: # Если уже остановлен - pass - - -# --- Тесты для функций извлечения текста --- + patcher_dotenv_indexer.stop() def test_extract_text_from_txt_success(): - """Тестирует успешное чтение UTF-8 TXT файла.""" mock_content = "Привет, мир!" - # Мокируем напрямую read_text у класса Path with patch.object(Path, 'read_text', return_value=mock_content) as mock_read: - # Создаем фиктивный объект Path для вызова функции - dummy_path = Path("dummy.txt") - result = indexer.extract_text_from_txt(dummy_path) + result = indexer.extract_text_from_txt(Path("dummy.txt")) assert result == mock_content - # Проверяем, что read_text был вызван с кодировкой utf-8 mock_read.assert_called_once_with(encoding='utf-8') def test_extract_text_from_txt_cp1251(): - """Тестирует чтение TXT файла в CP1251 после неудачи с UTF-8.""" - mock_content_cp1251_str = "Тест CP1251" - # Настраиваем side_effect для имитации попыток чтения - mock_read_text = MagicMock(name='read_text_mock') - mock_read_text.side_effect = [ - # Ошибка при первой попытке (UTF-8) - UnicodeDecodeError('utf-8', b'\xd2\xe5\xf1\xf2', 0, 1, 'invalid start byte'), - # Успешный результат при второй попытке (CP1251) - mock_content_cp1251_str, - # Результат для третьей попытки (Latin-1), на всякий случай - mock_content_cp1251_str + mock_content = "Тест CP1251" + mock_read = MagicMock() + mock_read.side_effect = [ + UnicodeDecodeError('utf-8', b'\xd2\xe5\xf1\xf2', 0, 1, 'invalid'), + mock_content, + mock_content ] - with patch.object(Path, 'read_text', mock_read_text): - dummy_path = Path("dummy_cp1251.txt") - result = indexer.extract_text_from_txt(dummy_path) - assert result == mock_content_cp1251_str - # Проверяем, что было две попытки вызова read_text - assert mock_read_text.call_count == 2 - # Проверяем аргументы кодировки для каждой попытки - assert mock_read_text.call_args_list[0][1]['encoding'] == 'utf-8' - assert mock_read_text.call_args_list[1][1]['encoding'] == 'cp1251' - + with patch.object(Path, 'read_text', mock_read): + result = indexer.extract_text_from_txt(Path("dummy.txt")) + assert result == mock_content + assert mock_read.call_count == 2 def test_extract_text_from_txt_unknown_encoding(): - """Тестирует случай, когда ни одна кодировка не подходит.""" - # Имитируем ошибку UnicodeDecodeError для всех трех попыток - mock_read_text = MagicMock(name='read_text_mock_fail') - mock_read_text.side_effect = [ - UnicodeDecodeError('utf-8', b'\xff\xfe', 0, 1, 'invalid bom'), - UnicodeDecodeError('cp1251', b'\xff\xfe', 0, 1, 'invalid sequence'), - UnicodeDecodeError('latin-1', b'\xff\xfe', 0, 1, 'invalid sequence') + mock_read = MagicMock() + mock_read.side_effect = [ + UnicodeDecodeError('utf-8', b'\xff\xfe', 0, 1, 'invalid'), + UnicodeDecodeError('cp1251', b'\xff\xfe', 0, 1, 'invalid'), + UnicodeDecodeError('latin-1', b'\xff\xfe', 0, 1, 'invalid') ] - with patch.object(Path, 'read_text', mock_read_text), \ - pytest.raises(ValueError, match="Unknown encoding for dummy_unknown.txt"): # Ожидаем ValueError - dummy_path = Path("dummy_unknown.txt") - indexer.extract_text_from_txt(dummy_path) - # Проверяем, что было сделано 3 попытки вызова read_text - assert mock_read_text.call_count == 3 + with patch.object(Path, 'read_text', mock_read), \ + pytest.raises(ValueError, match="Unknown encoding"): + indexer.extract_text_from_txt(Path("dummy.txt")) +@patch('backend.indexer.pdf_extract_text', return_value="PDF content") +def test_extract_text_from_pdf_success(mock_extract): + result = indexer.extract_text_from_pdf(Path("dummy.pdf")) + assert result == "PDF content" + mock_extract.assert_called_once() -@patch('backend.indexer.pdf_extract_text', return_value="PDF content here") -def test_extract_text_from_pdf_success(mock_pdf_extract): - """Тестирует успешное извлечение текста из PDF.""" - dummy_path = Path("dummy.pdf") - result = indexer.extract_text_from_pdf(dummy_path) - assert result == "PDF content here" - # Убедимся, что pdf_extract_text вызывается со строковым представлением пути - mock_pdf_extract.assert_called_once_with(str(dummy_path)) - - -@patch('backend.indexer.pdf_extract_text', side_effect=indexer.PDFSyntaxError("Bad PDF")) -def test_extract_text_from_pdf_syntax_error(mock_pdf_extract): - """Тестирует обработку PDFSyntaxError.""" - dummy_path = Path("dummy.pdf") - with pytest.raises(ValueError, match="Ошибка синтаксиса PDF: dummy.pdf"): - indexer.extract_text_from_pdf(dummy_path) - +@patch('backend.indexer.pdf_extract_text', side_effect=indexer.PDFSyntaxError("Error")) +def test_extract_text_from_pdf_error(mock_extract): + with pytest.raises(ValueError, match="Ошибка синтаксиса PDF"): + indexer.extract_text_from_pdf(Path("dummy.pdf")) @patch('backend.indexer.epub.read_epub') -def test_extract_text_from_epub_success(mock_read_epub): - """Тестирует успешное извлечение текста из EPUB.""" - # Создаем моки для epub объектов - mock_item1 = MagicMock(); mock_item1.get_type.return_value = indexer.ITEM_DOCUMENT - mock_item1.content = b"<html><body><p>First paragraph.</p></body></html>" - mock_item2 = MagicMock(); mock_item2.get_type.return_value = indexer.ITEM_DOCUMENT - mock_item2.content = b"<html><body><style>.test{}</style><p>Second.</p><script>alert();</script><div>Third</div></body></html>" - mock_book = MagicMock(); mock_book.get_items_of_type.return_value = [mock_item1, mock_item2] - mock_read_epub.return_value = mock_book # Настраиваем мок read_epub +def test_extract_text_from_epub_success(mock_read): + mock_item = MagicMock() + mock_item.get_type.return_value = indexer.ITEM_DOCUMENT + mock_item.content = b"<html><body><p>Test</p></body></html>" + mock_book = MagicMock() + mock_book.get_items_of_type.return_value = [mock_item] + mock_read.return_value = mock_book + + result = indexer.extract_text_from_epub(Path("dummy.epub")) + assert result == "Test" + mock_read.assert_called_once() - dummy_path = Path("dummy.epub") - result = indexer.extract_text_from_epub(dummy_path) - # Проверяем результат с учетом удаления тегов и добавления переносов строк - assert result == "First paragraph.\n\nSecond.\nThird" - # Убедимся, что read_epub вызывается со строковым представлением пути - mock_read_epub.assert_called_once_with(str(dummy_path)) - mock_book.get_items_of_type.assert_called_once_with(indexer.ITEM_DOCUMENT) +@patch('backend.indexer.epub.read_epub', side_effect=Exception("Error")) +def test_extract_text_from_epub_error(mock_read): + with pytest.raises(IOError, match="Не удалось обработать EPUB файл"): + indexer.extract_text_from_epub(Path("dummy.epub")) - -@patch('backend.indexer.epub.read_epub', side_effect=Exception("EPUB Read Error")) -def test_extract_text_from_epub_error(mock_read_epub): - """Тестирует обработку общей ошибки при чтении EPUB.""" - dummy_path = Path("dummy.epub") - with pytest.raises(IOError, match="Не удалось обработать EPUB файл dummy.epub"): - indexer.extract_text_from_epub(dummy_path) - - -# --- Тесты для process_file --- - -# Мокируем зависимости для process_file -@patch('pathlib.Path.stat') # Используется для получения mtime -@patch('backend.indexer.extract_text_from_txt') # Мокируем функцию извлечения -@patch('backend.indexer.time.time', return_value=99999.99) # Мокируем текущее время +@patch('pathlib.Path.stat') +@patch('backend.indexer.extract_text_from_txt') +@patch('backend.indexer.time.time', return_value=99999.99) def test_process_file_txt_success(mock_time, mock_extract, mock_stat): - """Тестирует успешную обработку TXT файла.""" - # Настройка моков - mock_stat_result = MagicMock(); mock_stat_result.st_mtime = 12345.67 + mock_stat_result = MagicMock() + mock_stat_result.st_mtime = 12345.67 mock_stat.return_value = mock_stat_result - mock_extract.return_value="Текстовый контент" # Что вернет функция извлечения - - # Создаем мок объекта Path для передачи в process_file + mock_extract.return_value = "Content" + p = MagicMock(spec=Path) p.name = "file.txt" p.suffix = ".txt" - # Важно: нужно чтобы str(p) возвращало имя файла для логов и т.п. p.__str__.return_value = "file.txt" - + p.stat.return_value = mock_stat_result + result = indexer.process_file(p) - - assert result is not None + assert result["id"] == "file.txt" - assert result["content"] == "Текстовый контент" - assert result["file_mtime"] == 12345.67 - assert result["indexed_at"] == 99999.99 # Проверяем мок времени - # Проверяем, что были вызваны нужные функции/методы - mock_extract.assert_called_once_with(p) - p.stat.assert_called_once() # Проверяем вызов stat у мока Path + assert result["content"] == "Content" + assert result["file_mtime"] == mock_stat_result.st_mtime + assert result["indexed_at"] == 99999.99 - -@patch('pathlib.Path.stat') # stat не должен вызваться -@patch('backend.indexer.extract_text_from_pdf', side_effect=IOError("PDF read failed")) -def test_process_file_pdf_error(mock_extract, mock_stat): - """Тестирует обработку ошибки при извлечении текста из PDF.""" +@patch('pathlib.Path.stat') +@patch('backend.indexer.extract_text_from_pdf', side_effect=IOError("Error")) +def test_process_file_error(mock_extract, mock_stat): p = MagicMock(spec=Path) - p.name = "broken.pdf" + p.name = "bad.pdf" p.suffix = ".pdf" - p.__str__.return_value = "broken.pdf" - + result = indexer.process_file(p) - - assert result is None # Ожидаем None при ошибке извлечения - mock_extract.assert_called_once_with(p) # Проверяем, что попытка извлечения была - mock_stat.assert_not_called() # stat не должен вызваться, т.к. функция вышла раньше - - -# Мокируем все функции извлечения, чтобы убедиться, что они НЕ вызываются -@patch('backend.indexer.extract_text_from_txt') -@patch('backend.indexer.extract_text_from_pdf') -@patch('backend.indexer.extract_text_from_epub') -@patch('pathlib.Path.stat') # stat тоже не должен вызваться -def test_process_file_unsupported_extension(mock_stat, mock_epub, mock_pdf, mock_txt): - """Тестирует обработку файла с неподдерживаемым расширением.""" - p = MagicMock(spec=Path) - p.name = "image.jpg" - p.suffix = ".jpg" - p.__str__.return_value = "image.jpg" - - result = indexer.process_file(p) - assert result is None - # Убедимся, что ни одна функция извлечения не вызывалась - mock_txt.assert_not_called() - mock_pdf.assert_not_called() - mock_epub.assert_not_called() + mock_extract.assert_called_once() mock_stat.assert_not_called() - -# --- Тесты для основной логики (scan_and_index_files) --- -# Мокируем все внешние зависимости scan_and_index_files -@patch('backend.indexer.Path') # Мокируем сам класс Path +@patch('backend.indexer.Path') @patch('backend.indexer.get_meili_client') @patch('backend.indexer.get_indexed_files') @patch('backend.indexer.update_meili_index') @patch('backend.indexer.delete_from_meili_index') -@patch('backend.indexer.process_file') # Мокируем обработку отдельного файла -def test_scan_and_index_new_file( - mock_process_file, mock_delete, mock_update, mock_get_indexed, mock_client_func, MockPath -): - """Тестирует сценарий добавления нового файла.""" - # 1. Настройка моков - # Мок клиента Meilisearch - mock_meili_client_instance = MagicMock() - mock_client_func.return_value = mock_meili_client_instance - # Мок ответа от Meilisearch (индекс пуст) +@patch('backend.indexer.process_file') +def test_scan_and_index_new_file(mock_process, mock_delete, mock_update, + mock_get_indexed, mock_client, MockPath): + mock_client.return_value = MagicMock() mock_get_indexed.return_value = {} - - # Мок объекта Path, представляющего директорию - mock_target_dir_instance = MagicMock(spec=Path) - mock_target_dir_instance.is_dir.return_value = True - # Настроим конструктор Path, чтобы он возвращал наш мок директории - MockPath.return_value = mock_target_dir_instance - - # Мок объекта Path, представляющего новый файл - new_file_path_mock = MagicMock(spec=Path) - new_file_path_mock.name = "new.txt" - new_file_path_mock.is_file.return_value = True - new_file_path_mock.suffix = ".txt" - # Мок для stat().st_mtime - stat_mock = MagicMock(); stat_mock.st_mtime = 100.0 - new_file_path_mock.stat.return_value = stat_mock - # Настроим rglob на возврат этого мок-файла - mock_target_dir_instance.rglob.return_value = [new_file_path_mock] - - # Мок результата успешной обработки файла - mock_process_file.return_value = {"id": "new.txt", "content": "new file", "file_mtime": 100.0, "indexed_at": 101.0} - - # 2. Запуск тестируемой функции + + mock_file = MagicMock(spec=Path) + mock_file.name = "new.txt" + mock_file.is_file.return_value = True + mock_file.suffix = ".txt" + stat_mock = MagicMock() + stat_mock.st_mtime = 100.0 + mock_file.stat.return_value = stat_mock + + mock_dir = MagicMock(spec=Path) + mock_dir.is_dir.return_value = True + mock_dir.rglob.return_value = [mock_file] + MockPath.return_value = mock_dir + + mock_process.return_value = { + "id": "new.txt", + "content": "content", + "file_mtime": 100.0, + "indexed_at": 101.0 + } + indexer.scan_and_index_files() - - # 3. Проверки вызовов - # Проверяем, что был создан Path для нужной директории - MockPath.assert_called_once_with(indexer.FILES_DIR) - # Проверяем, что была вызвана проверка is_dir - mock_target_dir_instance.is_dir.assert_called_once() - # Проверяем получение клиента Meili - mock_client_func.assert_called_once() - # Проверяем получение состояния индекса - mock_get_indexed.assert_called_once_with(mock_meili_client_instance) - # Проверяем сканирование файлов - mock_target_dir_instance.rglob.assert_called_once_with('*') - # Проверяем вызов stat для найденного файла - new_file_path_mock.stat.assert_called_once() - # Проверяем вызов обработки файла - mock_process_file.assert_called_once_with(new_file_path_mock) - # Проверяем вызов обновления индекса + mock_update.assert_called_once() - # Проверяем аргументы, переданные в update_meili_index - call_args, _ = mock_update.call_args - assert call_args[0] is mock_meili_client_instance # Проверяем переданный клиент - assert len(call_args[1]) == 1 # Проверяем, что передан один документ - assert call_args[1][0]["id"] == "new.txt" # Проверяем id документа - # Проверяем, что удаление не вызывалось mock_delete.assert_not_called() - -# TODO: Добавить больше тестов для scan_and_index_files, покрывающих другие сценарии: -# - Обновление существующего файла -# - Удаление файла -# - Файл не изменился -# - Ошибка при обработке файла (process_file возвращает None) -# - Ошибки при взаимодействии с Meilisearch (в get_indexed_files, update, delete)