2025-07-11 21:28:58 +02:00
|
|
|
|
import asyncio
|
|
|
|
|
import tempfile
|
|
|
|
|
from collections.abc import Generator
|
2025-07-12 10:43:02 +02:00
|
|
|
|
from datetime import datetime, timezone
|
2025-07-11 21:28:58 +02:00
|
|
|
|
from pathlib import Path
|
2025-07-12 10:43:02 +02:00
|
|
|
|
from typing import AsyncGenerator
|
|
|
|
|
from unittest.mock import MagicMock
|
2025-07-11 21:28:58 +02:00
|
|
|
|
|
|
|
|
|
import pytest
|
2025-07-12 10:43:02 +02:00
|
|
|
|
import pytest_asyncio
|
|
|
|
|
import structlog
|
|
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
from src.models import AppConfig
|
|
|
|
|
from src.models.article_dto import ArticleDTO, ArticleStatus
|
|
|
|
|
from src.services import ArticleRepository, DatabaseService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def level_to_int(logger, method_name, event_dict):
|
|
|
|
|
if isinstance(event_dict.get("level"), str):
|
|
|
|
|
try:
|
|
|
|
|
event_dict["level"] = getattr(logging, event_dict["level"].upper())
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return event_dict
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="session")
|
|
|
|
|
def configure_structlog():
|
|
|
|
|
import tenacity
|
|
|
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
|
|
|
structlog.configure(
|
|
|
|
|
processors=[
|
|
|
|
|
level_to_int,
|
|
|
|
|
structlog.processors.TimeStamper(fmt="iso"),
|
|
|
|
|
structlog.dev.ConsoleRenderer()
|
|
|
|
|
],
|
|
|
|
|
wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG)
|
|
|
|
|
)
|
|
|
|
|
tenacity.logger = structlog.get_logger("tenacity")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="session")
|
|
|
|
|
def patch_tenacity_before_sleep_log():
|
|
|
|
|
import logging
|
|
|
|
|
import tenacity
|
|
|
|
|
from tenacity.before_sleep import before_sleep_log
|
|
|
|
|
|
|
|
|
|
original_before_sleep_log = tenacity.before_sleep_log
|
2025-07-11 21:28:58 +02:00
|
|
|
|
|
2025-07-12 10:43:02 +02:00
|
|
|
|
def patched_before_sleep_log(logger, log_level):
|
|
|
|
|
if isinstance(log_level, str):
|
|
|
|
|
log_level = getattr(logging, log_level.upper(), logging.WARNING)
|
|
|
|
|
return original_before_sleep_log(logger, log_level)
|
|
|
|
|
|
|
|
|
|
tenacity.before_sleep_log = patched_before_sleep_log
|
2025-07-11 21:28:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
|
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
|
yield loop
|
|
|
|
|
loop.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def test_config() -> AppConfig:
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
db_path = Path(temp_dir) / "test.db"
|
|
|
|
|
return AppConfig(
|
|
|
|
|
openai_api_key="test_key",
|
|
|
|
|
openai_model="gpt-4o-mini",
|
|
|
|
|
db_path=str(db_path),
|
|
|
|
|
max_concurrent_llm=2,
|
|
|
|
|
openai_rpm=10,
|
|
|
|
|
max_concurrent_wiki=5,
|
|
|
|
|
prompt_template_path="src/prompt.txt",
|
|
|
|
|
log_level="DEBUG",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
2025-07-12 10:43:02 +02:00
|
|
|
|
def mock_openai_response():
|
|
|
|
|
mock_response = MagicMock()
|
|
|
|
|
mock_response.choices = [MagicMock()]
|
|
|
|
|
mock_response.choices[0].message.content = "Упрощённый текст для школьников"
|
|
|
|
|
mock_response.usage.prompt_tokens = 100
|
|
|
|
|
mock_response.usage.completion_tokens = 50
|
|
|
|
|
mock_response.__await__ = lambda: iter([mock_response])
|
|
|
|
|
return mock_response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def database_service(test_config: AppConfig) -> AsyncGenerator[DatabaseService, None]:
|
|
|
|
|
service = DatabaseService(test_config)
|
|
|
|
|
await service.initialize_database()
|
|
|
|
|
yield service
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def repository(database_service: DatabaseService) -> AsyncGenerator[ArticleRepository, None]:
|
|
|
|
|
repo = ArticleRepository(database_service)
|
|
|
|
|
yield repo
|
2025-07-11 21:28:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
2025-07-12 10:43:02 +02:00
|
|
|
|
def sample_wiki_urls() -> list[str]:
|
2025-07-11 21:28:58 +02:00
|
|
|
|
return [
|
2025-07-12 10:43:02 +02:00
|
|
|
|
"https://ru.ruwiki.ru/wiki/Тест",
|
|
|
|
|
"https://ru.ruwiki.ru/wiki/Пример",
|
|
|
|
|
"https://ru.ruwiki.ru/wiki/Образец",
|
2025-07-11 21:28:58 +02:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def sample_wikitext() -> str:
|
|
|
|
|
return """'''Тест''' — это проверка чего-либо.
|
|
|
|
|
|
|
|
|
|
== Определение ==
|
|
|
|
|
Тест может проводиться для различных целей:
|
|
|
|
|
* Проверка знаний
|
|
|
|
|
* Проверка работоспособности
|
|
|
|
|
* Проверка качества
|
|
|
|
|
|
|
|
|
|
== История ==
|
2025-07-12 10:43:02 +02:00
|
|
|
|
Тесты использовались с древних времён."""
|
2025-07-11 21:28:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def simplified_text() -> str:
|
2025-07-12 10:43:02 +02:00
|
|
|
|
return """Тест — это проверка чего-либо для школьников.
|
2025-07-11 21:28:58 +02:00
|
|
|
|
|
2025-07-12 10:43:02 +02:00
|
|
|
|
Что такое тест
|
2025-07-11 21:28:58 +02:00
|
|
|
|
Тест помогает проверить:
|
|
|
|
|
* Знания учеников
|
|
|
|
|
* Как работают устройства
|
|
|
|
|
* Качество продуктов
|
|
|
|
|
|
2025-07-12 10:43:02 +02:00
|
|
|
|
Когда появились тесты
|
|
|
|
|
Люди проверяли друг друга очень давно."""
|
2025-07-11 21:28:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
2025-07-12 10:43:02 +02:00
|
|
|
|
def sample_article_dto() -> ArticleDTO:
|
|
|
|
|
return ArticleDTO(
|
|
|
|
|
url="https://ru.ruwiki.ru/wiki/Тест",
|
2025-07-11 21:28:58 +02:00
|
|
|
|
title="Тест",
|
|
|
|
|
raw_text="Тестовый wiki-текст",
|
2025-07-12 10:43:02 +02:00
|
|
|
|
status=ArticleStatus.PENDING,
|
|
|
|
|
created_at=datetime.now(timezone.utc),
|
2025-07-11 21:28:58 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-07-12 10:43:02 +02:00
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def sample_article_in_db(
|
|
|
|
|
repository: ArticleRepository, sample_article_dto: ArticleDTO
|
|
|
|
|
) -> AsyncGenerator[ArticleDTO, None]:
|
|
|
|
|
article = await repository.create_article(
|
|
|
|
|
url=sample_article_dto.url,
|
|
|
|
|
title=sample_article_dto.title,
|
|
|
|
|
raw_text=sample_article_dto.raw_text,
|
2025-07-11 21:28:58 +02:00
|
|
|
|
)
|
2025-07-12 10:43:02 +02:00
|
|
|
|
yield article
|
2025-07-11 21:28:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def temp_input_file(sample_wiki_urls: list[str]) -> Generator[str, None, None]:
|
2025-07-12 10:43:02 +02:00
|
|
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
|
2025-07-11 21:28:58 +02:00
|
|
|
|
for url in sample_wiki_urls:
|
|
|
|
|
f.write(f"{url}\n")
|
|
|
|
|
temp_path = f.name
|
|
|
|
|
|
|
|
|
|
yield temp_path
|
|
|
|
|
Path(temp_path).unlink(missing_ok=True)
|
|
|
|
|
|
|
|
|
|
|
2025-07-12 10:43:02 +02:00
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def multiple_articles_in_db(
|
|
|
|
|
repository: ArticleRepository, sample_wiki_urls: list[str]
|
|
|
|
|
) -> AsyncGenerator[list[ArticleDTO], None]:
|
|
|
|
|
articles = []
|
|
|
|
|
for i, url in enumerate(sample_wiki_urls):
|
|
|
|
|
article = await repository.create_article(
|
|
|
|
|
url=url,
|
|
|
|
|
title=f"Test Article {i+1}",
|
|
|
|
|
raw_text=f"Content for article {i+1}",
|
|
|
|
|
)
|
|
|
|
|
articles.append(article)
|
|
|
|
|
yield articles
|