import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock, mock_open
import os
# Мокирование load_dotenv (остается)
patcher_dotenv_indexer = patch('dotenv.load_dotenv', return_value=True)
patcher_dotenv_indexer.start()
from backend import indexer
@pytest.fixture(scope="module", autouse=True)
def stop_indexer_patch():
yield
patcher_dotenv_indexer.stop()
# --- Тесты для функций извлечения текста ---
def test_extract_text_from_txt_success():
"""Тестирует успешное чтение UTF-8 TXT файла."""
mock_content = "Привет, мир!"
# Патчим open, используемый внутри read_text
m_open = mock_open(read_data=mock_content.encode('utf-8'))
with patch('pathlib.Path.open', m_open):
# Теперь Path("dummy.txt").read_text() будет использовать наш мок open
result = indexer.extract_text_from_txt(Path("dummy.txt"))
assert result == mock_content
# Проверяем, что open был вызван с правильными параметрами для read_text
m_open.assert_called_once_with('r', encoding='utf-8', errors=None) # read_text вызывает open с 'r'
def test_extract_text_from_txt_cp1251():
"""Тестирует чтение TXT файла в CP1251 после неудачи с UTF-8."""
mock_content_cp1251 = "Тест CP1251".encode('cp1251')
# Имитируем ошибки и успешное чтение через side_effect для open
m_open = mock_open()
handle_mock = m_open() # Получаем мок файлового дескриптора
# Определяем поведение read() для разных кодировок
def read_side_effect(*args, **kwargs):
# Первый вызов (UTF-8) должен вызвать ошибку при decode
# Второй вызов (CP1251) должен вернуть байты
# Третий (Latin-1) - тоже байты на всякий случай
if handle_mock.encoding == 'utf-8':
# Чтобы вызвать UnicodeDecodeError внутри read_text, вернем байты, которые не декодируются
return b'\xd2\xe5\xf1\xf2' # "Тест" в cp1251, вызовет ошибку в utf-8
elif handle_mock.encoding == 'cp1251':
return mock_content_cp1251
else: # latin-1
return mock_content_cp1251
handle_mock.read.side_effect = read_side_effect
# Патчим сам open
with patch('pathlib.Path.open', m_open):
result = indexer.extract_text_from_txt(Path("dummy_cp1251.txt"))
assert result == "Тест CP1251"
# Проверяем, что open вызывался дважды (для utf-8 и cp1251)
assert m_open.call_count == 2
assert m_open.call_args_list[0][1]['encoding'] == 'utf-8'
assert m_open.call_args_list[1][1]['encoding'] == 'cp1251'
def test_extract_text_from_txt_unknown_encoding():
"""Тестирует случай, когда ни одна кодировка не подходит."""
m_open = mock_open()
handle_mock = m_open()
# Имитируем ошибку чтения для всех кодировок
handle_mock.read.return_value = b'\xff\xfe' # BOM UTF-16, который не пройдет наши проверки
with patch('pathlib.Path.open', m_open), \
pytest.raises(ValueError, match="Unknown encoding for dummy_unknown.txt"):
indexer.extract_text_from_txt(Path("dummy_unknown.txt"))
assert m_open.call_count == 3 # Попытки utf-8, cp1251, latin-1
# ... (тесты для PDF и EPUB остаются как были, но можно проверить аргументы) ...
@patch('backend.indexer.pdf_extract_text', return_value="PDF content here")
def test_extract_text_from_pdf_success(mock_pdf_extract):
result = indexer.extract_text_from_pdf(Path("dummy.pdf"))
assert result == "PDF content here"
mock_pdf_extract.assert_called_once_with(str(Path("dummy.pdf"))) # Убедимся, что передается строка
@patch('backend.indexer.pdf_extract_text', side_effect=indexer.PDFSyntaxError("Bad PDF"))
def test_extract_text_from_pdf_syntax_error(mock_pdf_extract):
with pytest.raises(ValueError, match="Ошибка синтаксиса PDF: dummy.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):
mock_item1 = MagicMock(); mock_item1.get_type.return_value = indexer.ITEM_DOCUMENT
mock_item1.content = b"
First paragraph.
"
mock_item2 = MagicMock(); mock_item2.get_type.return_value = indexer.ITEM_DOCUMENT
mock_item2.content = b"Second.
Third
"
mock_book = MagicMock(); mock_book.get_items_of_type.return_value = [mock_item1, mock_item2]
mock_read_epub.return_value = mock_book
result = indexer.extract_text_from_epub(Path("dummy.epub"))
assert result == "First paragraph.\n\nSecond.\nThird" # Проверяем результат с учетом \n и strip
mock_read_epub.assert_called_once_with(str(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):
with pytest.raises(IOError, match="Не удалось обработать EPUB файл dummy.epub"):
indexer.extract_text_from_epub(Path("dummy.epub"))
# --- Тесты для process_file ---
# Патчим stat() вместо getmtime, так как process_file был изменен
@patch('pathlib.Path.stat')
@patch('backend.indexer.extract_text_from_txt', return_value="Текстовый контент")
# Патчим open, чтобы read_text внутри extract_text_from_txt работал
@patch('pathlib.Path.open', new_callable=mock_open, read_data=b'content')
def test_process_file_txt_success(mock_file_open, mock_extract, mock_stat):
"""Тестирует успешную обработку TXT файла."""
mock_stat_result = MagicMock()
mock_stat_result.st_mtime = 12345.67
mock_stat.return_value = mock_stat_result
p = Path("file.txt")
# Мокаем suffix напрямую у экземпляра Path, который будет создан
with patch.object(Path, 'suffix', '.txt'):
# Мокаем и name для единообразия
with patch.object(Path, 'name', "file.txt"):
result = indexer.process_file(p)
assert result is not None
assert result["id"] == "file.txt"
assert result["content"] == "Текстовый контент"
assert result["file_mtime"] == 12345.67
mock_extract.assert_called_once_with(p) # Проверяем вызов extract
mock_stat.assert_called_once() # Проверяем вызов stat
@patch('pathlib.Path.stat') # Нужен для вызова внутри process_file
@patch('backend.indexer.extract_text_from_pdf', side_effect=IOError("PDF read failed"))
def test_process_file_pdf_error(mock_extract, mock_stat):
"""Тестирует обработку ошибки при извлечении текста из PDF."""
# Мокируем stat, хотя он может не вызваться из-за раннего return
mock_stat_result = MagicMock(); mock_stat_result.st_mtime = 12345.67
mock_stat.return_value = mock_stat_result
p = Path("broken.pdf")
with patch.object(Path, 'suffix', '.pdf'):
with patch.object(Path, 'name', "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 не должен вызваться, так как ошибка была раньше
# МОК для stat НЕ НУЖЕН, так как функция выйдет раньше
@patch('backend.indexer.extract_text_from_txt') # Патчим на всякий случай
@patch('backend.indexer.extract_text_from_pdf')
@patch('backend.indexer.extract_text_from_epub')
def test_process_file_unsupported_extension(mock_epub, mock_pdf, mock_txt):
"""Тестирует обработку файла с неподдерживаемым расширением."""
p = Path("image.jpg")
# Мокаем только suffix
with patch.object(Path, 'suffix', '.jpg'):
with patch.object(Path, 'name', "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()
# --- Тесты для основной логики (scan_and_index_files) ---
# (Остается как был, т.к. он мокирует process_file целиком)
@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
):
"""Тестирует сценарий добавления нового файла."""
# Настройка моков
mock_meili_client_instance = MagicMock()
mock_client_func.return_value = mock_meili_client_instance
mock_get_indexed.return_value = {} # Индекс пуст
# Имитация экземпляра Path для директории
mock_target_dir_instance = MagicMock(spec=Path)
mock_target_dir_instance.is_dir.return_value = True
# Имитация файла на диске, возвращаемого rglob
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]
# Настроим конструктор Path
MockPath.return_value = mock_target_dir_instance
# Имитация успешной обработки файла process_file
mock_process_file.return_value = {"id": "new.txt", "content": "new file", "file_mtime": 100.0, "indexed_at": 101.0}
# Запуск функции
indexer.scan_and_index_files()
# Проверки (остаются как были)
MockPath.assert_called_once_with(indexer.FILES_DIR)
mock_target_dir_instance.is_dir.assert_called_once()
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('*')
mock_process_file.assert_called_once_with(new_file_path_mock)
mock_update.assert_called_once()
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"
mock_delete.assert_not_called()