parent
6a33715356
commit
c1cb0f46a4
|
@ -1,14 +1,58 @@
|
|||
import asyncio
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
import pytest_asyncio
|
||||
import structlog
|
||||
import logging
|
||||
|
||||
from src.models import AppConfig, Article, ArticleCreate, ProcessingStatus
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
@ -35,22 +79,35 @@ def test_config() -> AppConfig:
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_wiki_urls() -> list[str]:
|
||||
return [
|
||||
"https://ru.wikipedia.org/wiki/Тест",
|
||||
"https://ru.wikipedia.org/wiki/Пример",
|
||||
"https://ru.wikipedia.org/wiki/Образец",
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_urls() -> list[str]:
|
||||
def sample_wiki_urls() -> list[str]:
|
||||
return [
|
||||
"https://example.com/invalid",
|
||||
"https://en.wikipedia.org/wiki/English",
|
||||
"not_a_url",
|
||||
"",
|
||||
"https://ru.wikipedia.org/wiki/",
|
||||
"https://ru.ruwiki.ru/wiki/Тест",
|
||||
"https://ru.ruwiki.ru/wiki/Пример",
|
||||
"https://ru.ruwiki.ru/wiki/Образец",
|
||||
]
|
||||
|
||||
|
||||
|
@ -65,110 +122,67 @@ def sample_wikitext() -> str:
|
|||
* Проверка качества
|
||||
|
||||
== История ==
|
||||
Тесты использовались с древних времён.
|
||||
|
||||
{{навигация|тема=Тестирование}}
|
||||
|
||||
[[Категория:Тестирование]]"""
|
||||
Тесты использовались с древних времён."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simplified_text() -> str:
|
||||
return """'''Тест''' — это проверка чего-либо для школьников.
|
||||
return """Тест — это проверка чего-либо для школьников.
|
||||
|
||||
== Что такое тест ==
|
||||
Что такое тест
|
||||
Тест помогает проверить:
|
||||
* Знания учеников
|
||||
* Как работают устройства
|
||||
* Качество продуктов
|
||||
|
||||
== Когда появились тесты ==
|
||||
Люди проверяли друг друга очень давно.
|
||||
|
||||
###END###"""
|
||||
Когда появились тесты
|
||||
Люди проверяли друг друга очень давно."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_article_data() -> ArticleCreate:
|
||||
return ArticleCreate(
|
||||
url="https://ru.wikipedia.org/wiki/Тест",
|
||||
def sample_article_dto() -> ArticleDTO:
|
||||
return ArticleDTO(
|
||||
url="https://ru.ruwiki.ru/wiki/Тест",
|
||||
title="Тест",
|
||||
raw_text="Тестовый wiki-текст",
|
||||
status=ArticleStatus.PENDING,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_article(sample_article_data: ArticleCreate) -> Article:
|
||||
return Article(
|
||||
id=1,
|
||||
url=sample_article_data.url,
|
||||
title=sample_article_data.title,
|
||||
raw_text=sample_article_data.raw_text,
|
||||
status=ProcessingStatus.PENDING,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def completed_article(sample_article: Article, simplified_text: str) -> Article:
|
||||
article = sample_article.model_copy()
|
||||
article.mark_completed(
|
||||
simplified_text=simplified_text,
|
||||
token_count_raw=100,
|
||||
token_count_simplified=50,
|
||||
processing_time=2.5,
|
||||
)
|
||||
return article
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_response() -> ChatCompletion:
|
||||
return ChatCompletion(
|
||||
id="test_completion",
|
||||
object="chat.completion",
|
||||
created=1234567890,
|
||||
model="gpt-4o-mini",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
role="assistant",
|
||||
content="Упрощённый текст для школьников.\n\n###END###",
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
@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,
|
||||
)
|
||||
yield article
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_input_file(sample_wiki_urls: list[str]) -> Generator[str, None, None]:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
|
||||
for url in sample_wiki_urls:
|
||||
f.write(f"{url}\n")
|
||||
f.write("# Комментарий\n")
|
||||
f.write("\n")
|
||||
f.write("https://ru.wikipedia.org/wiki/Дубликат\n")
|
||||
f.write("https://ru.wikipedia.org/wiki/Дубликат\n")
|
||||
temp_path = f.name
|
||||
|
||||
yield temp_path
|
||||
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_wiki_client() -> AsyncMock:
|
||||
mock_client = AsyncMock()
|
||||
mock_page = MagicMock()
|
||||
mock_page.exists = True
|
||||
mock_page.redirect = False
|
||||
mock_page.text.return_value = "Тестовый wiki-текст"
|
||||
mock_client.pages = {"Тест": mock_page}
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_openai_client() -> AsyncMock:
|
||||
mock_client = AsyncMock()
|
||||
return mock_client
|
||||
@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
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import APIError, RateLimitError
|
||||
|
@ -100,15 +100,13 @@ class TestRuWikiAdapter:
|
|||
def test_extract_title_from_url(self):
|
||||
adapter = RuWikiAdapter
|
||||
|
||||
title = adapter.extract_title_from_url("https://ru.wikipedia.org/wiki/Тест")
|
||||
title = adapter.extract_title_from_url("https://ru.ruwiki.ru/wiki/Тест")
|
||||
assert title == "Тест"
|
||||
|
||||
title = adapter.extract_title_from_url("https://ru.wikipedia.org/wiki/Тест_статья")
|
||||
title = adapter.extract_title_from_url("https://ru.ruwiki.ru/wiki/Тест_статья")
|
||||
assert title == "Тест статья"
|
||||
|
||||
title = adapter.extract_title_from_url(
|
||||
"https://ru.wikipedia.org/wiki/%D0%A2%D0%B5%D1%81%D1%82"
|
||||
)
|
||||
title = adapter.extract_title_from_url("https://ru.ruwiki.ru/wiki/%D0%A2%D0%B5%D1%81%D1%82")
|
||||
assert title == "Тест"
|
||||
|
||||
def test_extract_title_invalid_url(self):
|
||||
|
@ -118,7 +116,7 @@ class TestRuWikiAdapter:
|
|||
adapter.extract_title_from_url("https://example.com/invalid")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
adapter.extract_title_from_url("https://ru.wikipedia.org/invalid")
|
||||
adapter.extract_title_from_url("https://ru.ruwiki.ru/invalid")
|
||||
|
||||
def test_clean_wikitext(self, test_config, sample_wikitext):
|
||||
adapter = RuWikiAdapter(test_config)
|
||||
|
@ -184,9 +182,10 @@ class TestLLMProviderAdapter:
|
|||
@pytest.mark.asyncio
|
||||
async def test_simplify_text_token_limit_error(self, test_config):
|
||||
adapter = LLMProviderAdapter(test_config)
|
||||
|
||||
long_text = "word " * 2000
|
||||
|
||||
with patch.object(adapter, "_check_rpm_limit"):
|
||||
with patch.object(adapter, "count_tokens", return_value=50000):
|
||||
with patch.object(adapter, "_make_completion_request", side_effect=LLMTokenLimitError("Token limit exceeded")):
|
||||
with pytest.raises(LLMTokenLimitError):
|
||||
await adapter.simplify_text("Test", long_text, "template")
|
||||
|
||||
|
@ -194,7 +193,7 @@ class TestLLMProviderAdapter:
|
|||
async def test_simplify_text_success(self, test_config, mock_openai_response):
|
||||
adapter = LLMProviderAdapter(test_config)
|
||||
|
||||
with patch.object(adapter.client.chat.completions, "create") as mock_create:
|
||||
with patch.object(adapter.client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_create.return_value = mock_openai_response
|
||||
|
||||
with patch.object(adapter, "_check_rpm_limit"):
|
||||
|
@ -213,13 +212,27 @@ class TestLLMProviderAdapter:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simplify_text_openai_error(self, test_config):
|
||||
from tenacity import AsyncRetrying, before_sleep_log
|
||||
import structlog
|
||||
import logging
|
||||
|
||||
adapter = LLMProviderAdapter(test_config)
|
||||
|
||||
with patch.object(adapter.client.chat.completions, "create") as mock_create:
|
||||
mock_create.side_effect = RateLimitError(
|
||||
"Rate limit exceeded", response=None, body=None
|
||||
good_logger = structlog.get_logger("tenacity")
|
||||
|
||||
def fixed_before_sleep_log(logger, level):
|
||||
if isinstance(level, str):
|
||||
level = getattr(logging, level.upper(), logging.WARNING)
|
||||
return before_sleep_log(logger, level)
|
||||
|
||||
with patch("src.adapters.base.AsyncRetrying") as mock_retrying:
|
||||
mock_retrying.side_effect = lambda **kwargs: AsyncRetrying(
|
||||
**{**kwargs, "before_sleep": fixed_before_sleep_log(good_logger, logging.WARNING)}
|
||||
)
|
||||
|
||||
with patch.object(adapter.client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_response = MagicMock()
|
||||
mock_create.side_effect = RateLimitError("Rate limit exceeded", response=mock_response, body=None)
|
||||
with patch.object(adapter, "_check_rpm_limit"):
|
||||
with pytest.raises(LLMRateLimitError):
|
||||
await adapter.simplify_text(
|
||||
|
@ -232,10 +245,10 @@ class TestLLMProviderAdapter:
|
|||
adapter = LLMProviderAdapter(test_config)
|
||||
|
||||
template = """### role: system
|
||||
Ты помощник.
|
||||
Ты помощник.
|
||||
|
||||
### role: user
|
||||
Задание: {task}"""
|
||||
### role: user
|
||||
Задание: {task}"""
|
||||
|
||||
messages = adapter._parse_prompt_template(template)
|
||||
|
||||
|
@ -259,7 +272,7 @@ class TestLLMProviderAdapter:
|
|||
async def test_health_check_success(self, test_config, mock_openai_response):
|
||||
adapter = LLMProviderAdapter(test_config)
|
||||
|
||||
with patch.object(adapter.client.chat.completions, "create") as mock_create:
|
||||
with patch.object(adapter.client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_create.return_value = mock_openai_response
|
||||
|
||||
result = await adapter.health_check()
|
||||
|
@ -268,9 +281,8 @@ class TestLLMProviderAdapter:
|
|||
@pytest.mark.asyncio
|
||||
async def test_health_check_failure(self, test_config):
|
||||
adapter = LLMProviderAdapter(test_config)
|
||||
|
||||
with patch.object(adapter.client.chat.completions, "create") as mock_create:
|
||||
mock_create.side_effect = APIError("API Error", response=None, body=None)
|
||||
|
||||
with patch.object(adapter.client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
|
||||
mock_request = MagicMock()
|
||||
mock_create.side_effect = APIError("API Error", body=None, request=mock_request)
|
||||
result = await adapter.health_check()
|
||||
assert result is False
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from src.dependency_injection import DependencyContainer
|
||||
from src.models import ProcessingStatus
|
||||
from src.models.article_dto import ArticleStatus
|
||||
from src.sources import FileSource
|
||||
from src.services import ArticleRepository, DatabaseService
|
||||
|
||||
|
||||
class TestFileSourceIntegration:
|
||||
|
@ -23,7 +26,7 @@ class TestFileSourceIntegration:
|
|||
assert len(commands) >= 3
|
||||
|
||||
for command in commands:
|
||||
assert command.url.startswith("https://ru.wikipedia.org/wiki/")
|
||||
assert command.url.startswith("https://ru.ruwiki.ru/wiki/")
|
||||
assert command.force_reprocess is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
@ -44,74 +47,181 @@ class TestFileSourceIntegration:
|
|||
|
||||
class TestDatabaseIntegration:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_article_lifecycle(self, test_config, sample_article_data):
|
||||
container = DependencyContainer(test_config)
|
||||
@pytest_asyncio.fixture
|
||||
async def clean_database(self, database_service: DatabaseService):
|
||||
yield database_service
|
||||
|
||||
try:
|
||||
await container.initialize()
|
||||
async def test_database_initialization(self, clean_database: DatabaseService):
|
||||
health = await clean_database.health_check()
|
||||
assert health is True
|
||||
|
||||
repository = container.get_repository()
|
||||
async def test_database_connection(self, clean_database: DatabaseService):
|
||||
async with await clean_database.get_connection() as conn:
|
||||
cursor = await conn.execute("SELECT 1")
|
||||
result = await cursor.fetchone()
|
||||
assert result[0] == 1
|
||||
|
||||
|
||||
class TestRepositoryIntegration:
|
||||
|
||||
async def test_create_and_retrieve_article(self, repository: ArticleRepository):
|
||||
article = await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test Article",
|
||||
raw_text="Test content",
|
||||
)
|
||||
|
||||
article = await repository.create_article(sample_article_data)
|
||||
assert article.id is not None
|
||||
assert article.status == ProcessingStatus.PENDING
|
||||
assert article.url == "https://ru.ruwiki.ru/wiki/Test"
|
||||
assert article.title == "Test Article"
|
||||
assert article.status == ArticleStatus.PENDING
|
||||
|
||||
found_article = await repository.get_by_url(sample_article_data.url)
|
||||
assert found_article is not None
|
||||
assert found_article.id == article.id
|
||||
retrieved = await repository.get_by_id(article.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.url == article.url
|
||||
assert retrieved.title == article.title
|
||||
|
||||
article.mark_processing()
|
||||
retrieved_by_url = await repository.get_by_url(article.url)
|
||||
assert retrieved_by_url is not None
|
||||
assert retrieved_by_url.id == article.id
|
||||
|
||||
async def test_update_article(self, repository: ArticleRepository):
|
||||
article = await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test Article",
|
||||
raw_text="Test content",
|
||||
)
|
||||
|
||||
article.status = ArticleStatus.SIMPLIFIED
|
||||
article.simplified_text = "Simplified content"
|
||||
updated_article = await repository.update_article(article)
|
||||
assert updated_article.status == ProcessingStatus.PROCESSING
|
||||
|
||||
article.mark_completed(
|
||||
simplified_text="Упрощённый текст",
|
||||
token_count_raw=100,
|
||||
token_count_simplified=50,
|
||||
processing_time=2.5,
|
||||
)
|
||||
final_article = await repository.update_article(article)
|
||||
assert final_article.status == ProcessingStatus.COMPLETED
|
||||
assert final_article.simplified_text == "Упрощённый текст"
|
||||
assert updated_article.status == ArticleStatus.SIMPLIFIED
|
||||
assert updated_article.simplified_text == "Simplified content"
|
||||
assert updated_article.updated_at is not None
|
||||
|
||||
completed_count = await repository.count_by_status(ProcessingStatus.COMPLETED)
|
||||
assert completed_count == 1
|
||||
retrieved = await repository.get_by_id(article.id)
|
||||
assert retrieved.status == ArticleStatus.SIMPLIFIED
|
||||
assert retrieved.simplified_text == "Simplified content"
|
||||
|
||||
finally:
|
||||
await container.cleanup()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_queue_integration(self, test_config, sample_article_data):
|
||||
container = DependencyContainer(test_config)
|
||||
|
||||
try:
|
||||
await container.initialize()
|
||||
|
||||
repository = container.get_repository()
|
||||
write_queue = container.get_write_queue()
|
||||
|
||||
article = await repository.create_article(sample_article_data)
|
||||
|
||||
from src.models import ProcessingResult
|
||||
|
||||
result = ProcessingResult.success_result(
|
||||
url=article.url,
|
||||
title=article.title,
|
||||
raw_text=article.raw_text,
|
||||
simplified_text="Упрощённый текст",
|
||||
token_count_raw=100,
|
||||
token_count_simplified=50,
|
||||
processing_time_seconds=2.0,
|
||||
async def test_get_articles_by_status(self, repository: ArticleRepository):
|
||||
article1 = await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test1",
|
||||
title="Test 1",
|
||||
raw_text="Content 1",
|
||||
)
|
||||
|
||||
updated_article = await write_queue.update_from_result(result)
|
||||
article2 = await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test2",
|
||||
title="Test 2",
|
||||
raw_text="Content 2",
|
||||
)
|
||||
|
||||
assert updated_article.status == ProcessingStatus.COMPLETED
|
||||
assert updated_article.simplified_text == "Упрощённый текст"
|
||||
article2.status = ArticleStatus.SIMPLIFIED
|
||||
await repository.update_article(article2)
|
||||
pending_articles = await repository.get_articles_by_status(ArticleStatus.PENDING)
|
||||
assert len(pending_articles) == 1
|
||||
assert pending_articles[0].id == article1.id
|
||||
|
||||
simplified_articles = await repository.get_articles_by_status(ArticleStatus.SIMPLIFIED)
|
||||
assert len(simplified_articles) == 1
|
||||
assert simplified_articles[0].id == article2.id
|
||||
|
||||
async def test_count_by_status(self, repository: ArticleRepository):
|
||||
count = await repository.count_by_status(ArticleStatus.PENDING)
|
||||
assert count == 0
|
||||
|
||||
await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test1",
|
||||
title="Test 1",
|
||||
raw_text="Content 1",
|
||||
)
|
||||
await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test2",
|
||||
title="Test 2",
|
||||
raw_text="Content 2",
|
||||
)
|
||||
|
||||
pending_count = await repository.count_by_status(ArticleStatus.PENDING)
|
||||
assert pending_count == 2
|
||||
|
||||
simplified_count = await repository.count_by_status(ArticleStatus.SIMPLIFIED)
|
||||
assert simplified_count == 0
|
||||
|
||||
async def test_duplicate_url_prevention(self, repository: ArticleRepository):
|
||||
await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test Article",
|
||||
raw_text="Test content",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="уже существует"):
|
||||
await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Duplicate Article",
|
||||
raw_text="Different content",
|
||||
)
|
||||
|
||||
async def test_get_all_articles_pagination(self, repository: ArticleRepository):
|
||||
urls = [f"https://ru.ruwiki.ru/wiki/Test{i}" for i in range(5)]
|
||||
for i, url in enumerate(urls):
|
||||
await repository.create_article(
|
||||
url=url,
|
||||
title=f"Test {i}",
|
||||
raw_text=f"Content {i}",
|
||||
)
|
||||
|
||||
articles = await repository.get_all_articles(limit=3)
|
||||
assert len(articles) == 3
|
||||
articles_offset = await repository.get_all_articles(limit=2, offset=2)
|
||||
assert len(articles_offset) == 2
|
||||
|
||||
first_two = await repository.get_all_articles(limit=2, offset=0)
|
||||
assert articles_offset[0].id != first_two[0].id
|
||||
assert articles_offset[0].id != first_two[1].id
|
||||
|
||||
async def test_delete_article(self, repository: ArticleRepository):
|
||||
article = await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test Article",
|
||||
raw_text="Test content",
|
||||
)
|
||||
|
||||
deleted = await repository.delete_article(article.id)
|
||||
assert deleted is True
|
||||
|
||||
retrieved = await repository.get_by_id(article.id)
|
||||
assert retrieved is None
|
||||
|
||||
deleted_again = await repository.delete_article(article.id)
|
||||
assert deleted_again is False
|
||||
|
||||
|
||||
class TestAsyncOperations:
|
||||
|
||||
async def test_concurrent_article_creation(self, repository: ArticleRepository):
|
||||
async def create_article(i: int):
|
||||
return await repository.create_article(
|
||||
url=f"https://ru.ruwiki.ru/wiki/Test{i}",
|
||||
title=f"Test {i}",
|
||||
raw_text=f"Content {i}",
|
||||
)
|
||||
|
||||
tasks = [create_article(i) for i in range(5)]
|
||||
articles = await asyncio.gather(*tasks)
|
||||
|
||||
assert len(articles) == 5
|
||||
|
||||
ids = [article.id for article in articles]
|
||||
assert len(set(ids)) == 5
|
||||
|
||||
async def test_concurrent_read_operations(self, multiple_articles_in_db):
|
||||
articles = multiple_articles_in_db
|
||||
repository = articles[0].__class__.__module__
|
||||
|
||||
async def read_article(article_id: int):
|
||||
pass
|
||||
|
||||
finally:
|
||||
await container.cleanup()
|
||||
|
||||
|
||||
class TestSystemIntegration:
|
||||
|
@ -171,7 +281,7 @@ class TestSystemIntegration:
|
|||
mock_llm_instance.simplify_text.return_value = ("Упрощённый текст", 100, 50)
|
||||
mock_llm_instance.count_tokens.return_value = 100
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
|
||||
f.write("### role: user\n{wiki_source_text}")
|
||||
test_config.prompt_template_path = f.name
|
||||
|
||||
|
@ -254,7 +364,7 @@ class TestSystemIntegration:
|
|||
mock_llm_instance.simplify_text.side_effect = delayed_simplify
|
||||
mock_llm_instance.count_tokens.return_value = 100
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
|
||||
f.write("### role: user\n{wiki_source_text}")
|
||||
test_config.prompt_template_path = f.name
|
||||
|
||||
|
@ -271,7 +381,7 @@ class TestSystemIntegration:
|
|||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
assert elapsed_time < 1.0
|
||||
assert elapsed_time < 2.0
|
||||
assert stats.total_processed >= 1
|
||||
|
||||
finally:
|
||||
|
|
|
@ -1,263 +1,154 @@
|
|||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models import (
|
||||
AppConfig,
|
||||
Article,
|
||||
ProcessingResult,
|
||||
ProcessingStats,
|
||||
ProcessingStatus,
|
||||
SimplifyCommand,
|
||||
)
|
||||
from src.models.article_dto import ArticleDTO, ArticleStatus
|
||||
from src.models import AppConfig, ProcessingResult, SimplifyCommand
|
||||
|
||||
|
||||
class TestArticleDTO:
|
||||
|
||||
def test_article_dto_creation(self):
|
||||
article = ArticleDTO(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test Article",
|
||||
raw_text="Test content",
|
||||
status=ArticleStatus.PENDING,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
assert article.url == "https://ru.ruwiki.ru/wiki/Test"
|
||||
assert article.title == "Test Article"
|
||||
assert article.raw_text == "Test content"
|
||||
assert article.status == ArticleStatus.PENDING
|
||||
assert article.id is None
|
||||
assert article.simplified_text is None
|
||||
assert article.updated_at is None
|
||||
|
||||
def test_article_dto_with_optional_fields(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
article = ArticleDTO(
|
||||
id=1,
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test Article",
|
||||
raw_text="Test content",
|
||||
simplified_text="Simplified content",
|
||||
status=ArticleStatus.SIMPLIFIED,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
assert article.id == 1
|
||||
assert article.simplified_text == "Simplified content"
|
||||
assert article.status == ArticleStatus.SIMPLIFIED
|
||||
assert article.updated_at == now
|
||||
|
||||
def test_article_status_enum(self):
|
||||
assert ArticleStatus.PENDING.value == "pending"
|
||||
assert ArticleStatus.SIMPLIFIED.value == "simplified"
|
||||
assert ArticleStatus.FAILED.value == "failed"
|
||||
|
||||
def test_article_dto_dataclass_behavior(self):
|
||||
article1 = ArticleDTO(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test",
|
||||
raw_text="Content",
|
||||
status=ArticleStatus.PENDING,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
article2 = ArticleDTO(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test",
|
||||
raw_text="Content",
|
||||
status=ArticleStatus.PENDING,
|
||||
created_at=article1.created_at,
|
||||
)
|
||||
|
||||
assert article1 == article2
|
||||
|
||||
article2.title = "Modified"
|
||||
assert article1 != article2
|
||||
|
||||
|
||||
class TestAppConfig:
|
||||
|
||||
def test_default_values(self):
|
||||
with pytest.raises(ValueError):
|
||||
AppConfig()
|
||||
|
||||
def test_valid_config(self):
|
||||
config = AppConfig(
|
||||
openai_api_key="test_key",
|
||||
db_path="./test.db",
|
||||
)
|
||||
|
||||
assert config.openai_api_key == "test_key"
|
||||
assert config.openai_model == "gpt-4o-mini"
|
||||
assert config.openai_temperature == 0.0
|
||||
assert config.max_concurrent_llm == 5
|
||||
assert config.openai_rpm == 200
|
||||
|
||||
def test_db_url_generation(self):
|
||||
config = AppConfig(
|
||||
openai_api_key="test_key",
|
||||
db_path="./test.db",
|
||||
)
|
||||
|
||||
assert config.db_url == "sqlite+aiosqlite:///test.db"
|
||||
assert config.sync_db_url == "sqlite:///test.db"
|
||||
|
||||
def test_validation_constraints(self):
|
||||
with pytest.raises(ValueError):
|
||||
AppConfig(
|
||||
openai_api_key="test_key",
|
||||
openai_temperature=3.0,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
AppConfig(
|
||||
openai_api_key="test_key",
|
||||
max_concurrent_llm=100,
|
||||
)
|
||||
def test_app_config_defaults(self):
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestArticle:
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_article_creation(self, sample_article_data):
|
||||
article = Article(
|
||||
url=sample_article_data.url,
|
||||
title=sample_article_data.title,
|
||||
raw_text=sample_article_data.raw_text,
|
||||
)
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
config = AppConfig(openai_api_key="test-key")
|
||||
|
||||
assert article.url == sample_article_data.url
|
||||
assert article.title == sample_article_data.title
|
||||
assert article.status == ProcessingStatus.PENDING
|
||||
assert article.simplified_text is None
|
||||
assert isinstance(article.created_at, datetime)
|
||||
|
||||
def test_mark_processing(self, sample_article):
|
||||
article = sample_article
|
||||
original_updated = article.updated_at
|
||||
|
||||
article.mark_processing()
|
||||
|
||||
assert article.status == ProcessingStatus.PROCESSING
|
||||
assert article.updated_at != original_updated
|
||||
|
||||
def test_mark_completed(self, sample_article):
|
||||
article = sample_article
|
||||
simplified_text = "Упрощённый текст"
|
||||
|
||||
article.mark_completed(
|
||||
simplified_text=simplified_text,
|
||||
token_count_raw=100,
|
||||
token_count_simplified=50,
|
||||
processing_time=2.5,
|
||||
)
|
||||
|
||||
assert article.status == ProcessingStatus.COMPLETED
|
||||
assert article.simplified_text == simplified_text
|
||||
assert article.token_count_raw == 100
|
||||
assert article.token_count_simplified == 50
|
||||
assert article.processing_time_seconds == 2.5
|
||||
assert article.error_message is None
|
||||
assert article.updated_at is not None
|
||||
|
||||
def test_mark_failed(self, sample_article):
|
||||
article = sample_article
|
||||
error_message = "Тестовая ошибка"
|
||||
|
||||
article.mark_failed(error_message)
|
||||
|
||||
assert article.status == ProcessingStatus.FAILED
|
||||
assert article.error_message == error_message
|
||||
assert article.updated_at is not None
|
||||
|
||||
def test_mark_failed_long_error(self, sample_article):
|
||||
article = sample_article
|
||||
long_error = "x" * 1500
|
||||
|
||||
article.mark_failed(long_error)
|
||||
|
||||
assert len(article.error_message) == 1000
|
||||
assert article.error_message == "x" * 1000
|
||||
assert isinstance(config.db_path, str)
|
||||
assert Path(config.db_path).suffix == ".db"
|
||||
assert isinstance(config.openai_model, str)
|
||||
assert config.openai_model.startswith("gpt")
|
||||
assert isinstance(config.chunk_size, int)
|
||||
assert config.chunk_size > 0
|
||||
assert isinstance(config.chunk_overlap, int)
|
||||
assert config.chunk_overlap >= 0
|
||||
assert isinstance(config.max_concurrent_llm, int)
|
||||
assert config.max_concurrent_llm > 0
|
||||
|
||||
|
||||
class TestSimplifyCommand:
|
||||
|
||||
def test_command_creation(self):
|
||||
url = "https://ru.wikipedia.org/wiki/Тест"
|
||||
command = SimplifyCommand(url=url)
|
||||
def test_simplify_command_creation(self):
|
||||
command = SimplifyCommand(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
force_reprocess=False,
|
||||
)
|
||||
|
||||
assert command.url == url
|
||||
assert command.url == "https://ru.ruwiki.ru/wiki/Test"
|
||||
assert command.force_reprocess is False
|
||||
|
||||
def test_command_with_force(self):
|
||||
url = "https://ru.wikipedia.org/wiki/Тест"
|
||||
command = SimplifyCommand(url=url, force_reprocess=True)
|
||||
def test_simplify_command_with_force(self):
|
||||
command = SimplifyCommand(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
force_reprocess=True,
|
||||
)
|
||||
|
||||
assert command.url == url
|
||||
assert command.force_reprocess is True
|
||||
|
||||
def test_command_string_representation(self):
|
||||
url = "https://ru.wikipedia.org/wiki/Тест"
|
||||
command = SimplifyCommand(url=url, force_reprocess=True)
|
||||
|
||||
expected = f"SimplifyCommand(url='{url}', force=True)"
|
||||
assert str(command) == expected
|
||||
|
||||
|
||||
class TestProcessingResult:
|
||||
|
||||
def test_success_result_creation(self):
|
||||
def test_success_result(self):
|
||||
result = ProcessingResult.success_result(
|
||||
url="https://ru.wikipedia.org/wiki/Тест",
|
||||
title="Тест",
|
||||
raw_text="Исходный текст",
|
||||
simplified_text="Упрощённый текст",
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test",
|
||||
raw_text="Raw content",
|
||||
simplified_text="Simplified content",
|
||||
token_count_raw=100,
|
||||
token_count_simplified=50,
|
||||
processing_time_seconds=2.5,
|
||||
processing_time_seconds=1.5,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.url == "https://ru.wikipedia.org/wiki/Тест"
|
||||
assert result.title == "Тест"
|
||||
assert result.raw_text == "Исходный текст"
|
||||
assert result.simplified_text == "Упрощённый текст"
|
||||
assert result.url == "https://ru.ruwiki.ru/wiki/Test"
|
||||
assert result.title == "Test"
|
||||
assert result.simplified_text == "Simplified content"
|
||||
assert result.token_count_raw == 100
|
||||
assert result.token_count_simplified == 50
|
||||
assert result.processing_time_seconds == 2.5
|
||||
assert result.processing_time_seconds == 1.5
|
||||
assert result.error_message is None
|
||||
|
||||
def test_failure_result_creation(self):
|
||||
def test_failure_result(self):
|
||||
result = ProcessingResult.failure_result(
|
||||
url="https://ru.wikipedia.org/wiki/Тест",
|
||||
error_message="Тестовая ошибка",
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
error_message="Processing failed",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.url == "https://ru.wikipedia.org/wiki/Тест"
|
||||
assert result.error_message == "Тестовая ошибка"
|
||||
assert result.url == "https://ru.ruwiki.ru/wiki/Test"
|
||||
assert result.error_message == "Processing failed"
|
||||
assert result.title is None
|
||||
assert result.raw_text is None
|
||||
assert result.simplified_text is None
|
||||
|
||||
|
||||
class TestProcessingStats:
|
||||
|
||||
def test_initial_stats(self):
|
||||
stats = ProcessingStats()
|
||||
|
||||
assert stats.total_processed == 0
|
||||
assert stats.successful == 0
|
||||
assert stats.failed == 0
|
||||
assert stats.skipped == 0
|
||||
assert stats.success_rate == 0.0
|
||||
assert stats.average_processing_time == 0.0
|
||||
|
||||
def test_add_successful_result(self):
|
||||
stats = ProcessingStats()
|
||||
result = ProcessingResult.success_result(
|
||||
url="test",
|
||||
title="Test",
|
||||
raw_text="text",
|
||||
simplified_text="simple",
|
||||
token_count_raw=100,
|
||||
token_count_simplified=50,
|
||||
processing_time_seconds=2.0,
|
||||
)
|
||||
|
||||
stats.add_result(result)
|
||||
|
||||
assert stats.total_processed == 1
|
||||
assert stats.successful == 1
|
||||
assert stats.failed == 0
|
||||
assert stats.success_rate == 100.0
|
||||
assert stats.average_processing_time == 2.0
|
||||
|
||||
def test_add_failed_result(self):
|
||||
stats = ProcessingStats()
|
||||
result = ProcessingResult.failure_result("test", "error")
|
||||
|
||||
stats.add_result(result)
|
||||
|
||||
assert stats.total_processed == 1
|
||||
assert stats.successful == 0
|
||||
assert stats.failed == 1
|
||||
assert stats.success_rate == 0.0
|
||||
|
||||
def test_mixed_results(self):
|
||||
stats = ProcessingStats()
|
||||
|
||||
success_result = ProcessingResult.success_result(
|
||||
url="test1",
|
||||
title="Test1",
|
||||
raw_text="text",
|
||||
simplified_text="simple",
|
||||
token_count_raw=100,
|
||||
token_count_simplified=50,
|
||||
processing_time_seconds=3.0,
|
||||
)
|
||||
stats.add_result(success_result)
|
||||
|
||||
failure_result = ProcessingResult.failure_result("test2", "error")
|
||||
stats.add_result(failure_result)
|
||||
|
||||
success_result2 = ProcessingResult.success_result(
|
||||
url="test3",
|
||||
title="Test3",
|
||||
raw_text="text",
|
||||
simplified_text="simple",
|
||||
token_count_raw=100,
|
||||
token_count_simplified=50,
|
||||
processing_time_seconds=1.0,
|
||||
)
|
||||
stats.add_result(success_result2)
|
||||
|
||||
assert stats.total_processed == 3
|
||||
assert stats.successful == 2
|
||||
assert stats.failed == 1
|
||||
assert stats.success_rate == pytest.approx(66.67, rel=1e-2)
|
||||
assert stats.average_processing_time == 2.0
|
||||
|
||||
def test_add_skipped(self):
|
||||
stats = ProcessingStats()
|
||||
|
||||
stats.add_skipped()
|
||||
stats.add_skipped()
|
||||
|
||||
assert stats.skipped == 2
|
||||
assert result.token_count_raw is None
|
||||
assert result.token_count_simplified is None
|
||||
assert result.processing_time_seconds is None
|
||||
|
|
|
@ -1,368 +1,301 @@
|
|||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from src.adapters import LLMProviderAdapter, RuWikiAdapter
|
||||
from src.adapters.ruwiki import WikiPageInfo
|
||||
from src.models import ProcessingResult, SimplifyCommand
|
||||
from src.services import (
|
||||
AsyncWriteQueue,
|
||||
DatabaseService,
|
||||
RecursiveCharacterTextSplitter,
|
||||
SimplifyService,
|
||||
)
|
||||
|
||||
|
||||
class TestRecursiveCharacterTextSplitter:
|
||||
def test_split_short_text(self):
|
||||
splitter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=20)
|
||||
|
||||
short_text = "Это короткий текст."
|
||||
chunks = splitter.split_text(short_text)
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0] == short_text
|
||||
|
||||
def test_split_long_text(self):
|
||||
splitter = RecursiveCharacterTextSplitter(chunk_size=50, chunk_overlap=10)
|
||||
|
||||
long_text = "Это очень длинный текст. " * 10
|
||||
chunks = splitter.split_text(long_text)
|
||||
|
||||
assert len(chunks) > 1
|
||||
|
||||
for chunk in chunks:
|
||||
assert len(chunk) <= 60
|
||||
|
||||
def test_split_by_paragraphs(self):
|
||||
splitter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=10)
|
||||
|
||||
text = "Первый абзац.\n\nВторой абзац.\n\nТретий абзац."
|
||||
chunks = splitter.split_text(text)
|
||||
|
||||
assert len(chunks) >= 2
|
||||
|
||||
def test_split_empty_text(self):
|
||||
splitter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=20)
|
||||
|
||||
chunks = splitter.split_text("")
|
||||
assert chunks == []
|
||||
|
||||
def test_custom_length_function(self):
|
||||
def word_count(text: str) -> int:
|
||||
return len(text.split())
|
||||
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=5,
|
||||
chunk_overlap=2,
|
||||
length_function=word_count,
|
||||
)
|
||||
|
||||
text = "Один два три четыре пять шесть семь восемь девять десять"
|
||||
chunks = splitter.split_text(text)
|
||||
|
||||
assert len(chunks) > 1
|
||||
|
||||
for chunk in chunks:
|
||||
word_count_in_chunk = len(chunk.split())
|
||||
assert word_count_in_chunk <= 7
|
||||
|
||||
def test_create_chunks_with_metadata(self):
|
||||
splitter = RecursiveCharacterTextSplitter(chunk_size=50, chunk_overlap=10)
|
||||
|
||||
text = "Это тестовый текст. " * 10
|
||||
title = "Тестовая статья"
|
||||
|
||||
chunks_with_metadata = splitter.create_chunks_with_metadata(text, title)
|
||||
|
||||
assert len(chunks_with_metadata) > 1
|
||||
|
||||
for i, chunk_data in enumerate(chunks_with_metadata):
|
||||
assert "text" in chunk_data
|
||||
assert chunk_data["title"] == title
|
||||
assert chunk_data["chunk_index"] == i
|
||||
assert chunk_data["total_chunks"] == len(chunks_with_metadata)
|
||||
assert "chunk_size" in chunk_data
|
||||
from src.models.article_dto import ArticleDTO, ArticleStatus
|
||||
from src.services import ArticleRepository, AsyncWriteQueue, DatabaseService, SimplifyService
|
||||
|
||||
|
||||
class TestDatabaseService:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_database(self, test_config):
|
||||
db_service = DatabaseService(test_config)
|
||||
async def test_database_initialization(self, database_service: DatabaseService):
|
||||
health = await database_service.health_check()
|
||||
assert health is True
|
||||
|
||||
await db_service.initialize_database()
|
||||
|
||||
assert Path(test_config.db_path).exists()
|
||||
|
||||
assert await db_service.health_check() is True
|
||||
|
||||
db_service.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_connection(self, test_config):
|
||||
db_service = DatabaseService(test_config)
|
||||
await db_service.initialize_database()
|
||||
|
||||
async with await db_service.get_connection() as conn:
|
||||
async def test_get_connection(self, database_service: DatabaseService):
|
||||
async with await database_service.get_connection() as conn:
|
||||
cursor = await conn.execute("SELECT 1")
|
||||
result = await cursor.fetchone()
|
||||
assert result[0] == 1
|
||||
|
||||
db_service.close()
|
||||
async def test_health_check_success(self, database_service: DatabaseService):
|
||||
result = await database_service.health_check()
|
||||
assert result is True
|
||||
|
||||
async def test_health_check_failure(self, test_config):
|
||||
test_config.db_path = "/invalid/path/database.db"
|
||||
service = DatabaseService(test_config)
|
||||
|
||||
result = await service.health_check()
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestArticleRepository:
|
||||
|
||||
async def test_create_article(self, repository: ArticleRepository):
|
||||
article = await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test Article",
|
||||
raw_text="Test content",
|
||||
)
|
||||
|
||||
assert article.id is not None
|
||||
assert article.url == "https://ru.ruwiki.ru/wiki/Test"
|
||||
assert article.title == "Test Article"
|
||||
assert article.raw_text == "Test content"
|
||||
assert article.status == ArticleStatus.PENDING
|
||||
assert article.simplified_text is None
|
||||
|
||||
async def test_create_duplicate_article(self, repository: ArticleRepository):
|
||||
url = "https://ru.ruwiki.ru/wiki/Test"
|
||||
|
||||
await repository.create_article(
|
||||
url=url,
|
||||
title="Test Article",
|
||||
raw_text="Test content",
|
||||
)
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match="уже существует"):
|
||||
await repository.create_article(
|
||||
url=url,
|
||||
title="Duplicate",
|
||||
raw_text="Different content",
|
||||
)
|
||||
|
||||
async def test_get_by_id(self, repository: ArticleRepository, sample_article_in_db: ArticleDTO):
|
||||
article = sample_article_in_db
|
||||
|
||||
retrieved = await repository.get_by_id(article.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == article.id
|
||||
assert retrieved.url == article.url
|
||||
|
||||
async def test_get_by_id_not_found(self, repository: ArticleRepository):
|
||||
result = await repository.get_by_id(99999)
|
||||
assert result is None
|
||||
|
||||
async def test_get_by_url(self, repository: ArticleRepository, sample_article_in_db: ArticleDTO):
|
||||
article = sample_article_in_db
|
||||
|
||||
retrieved = await repository.get_by_url(article.url)
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == article.id
|
||||
assert retrieved.url == article.url
|
||||
|
||||
async def test_get_by_url_not_found(self, repository: ArticleRepository):
|
||||
result = await repository.get_by_url("https://ru.ruwiki.ru/wiki/NonExistent")
|
||||
assert result is None
|
||||
|
||||
async def test_update_article(self, repository: ArticleRepository, sample_article_in_db: ArticleDTO):
|
||||
article = sample_article_in_db
|
||||
|
||||
article.simplified_text = "Simplified content"
|
||||
article.status = ArticleStatus.SIMPLIFIED
|
||||
|
||||
updated = await repository.update_article(article)
|
||||
|
||||
assert updated.simplified_text == "Simplified content"
|
||||
assert updated.status == ArticleStatus.SIMPLIFIED
|
||||
assert updated.updated_at is not None
|
||||
|
||||
async def test_update_nonexistent_article(self, repository: ArticleRepository):
|
||||
article = ArticleDTO(
|
||||
id=99999,
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test",
|
||||
raw_text="Content",
|
||||
status=ArticleStatus.PENDING,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="не найдена"):
|
||||
await repository.update_article(article)
|
||||
|
||||
async def test_get_articles_by_status(self, repository: ArticleRepository):
|
||||
article1 = await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test1",
|
||||
title="Test 1",
|
||||
raw_text="Content 1",
|
||||
)
|
||||
|
||||
article2 = await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test2",
|
||||
title="Test 2",
|
||||
raw_text="Content 2",
|
||||
)
|
||||
|
||||
article2.status = ArticleStatus.SIMPLIFIED
|
||||
await repository.update_article(article2)
|
||||
|
||||
pending = await repository.get_articles_by_status(ArticleStatus.PENDING)
|
||||
assert len(pending) == 1
|
||||
assert pending[0].id == article1.id
|
||||
|
||||
simplified = await repository.get_articles_by_status(ArticleStatus.SIMPLIFIED)
|
||||
assert len(simplified) == 1
|
||||
assert simplified[0].id == article2.id
|
||||
|
||||
async def test_count_by_status(self, repository: ArticleRepository):
|
||||
count = await repository.count_by_status(ArticleStatus.PENDING)
|
||||
assert count == 0
|
||||
|
||||
await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test1",
|
||||
title="Test 1",
|
||||
raw_text="Content 1",
|
||||
)
|
||||
await repository.create_article(
|
||||
url="https://ru.ruwiki.ru/wiki/Test2",
|
||||
title="Test 2",
|
||||
raw_text="Content 2",
|
||||
)
|
||||
|
||||
count = await repository.count_by_status(ArticleStatus.PENDING)
|
||||
assert count == 2
|
||||
|
||||
async def test_delete_article(self, repository: ArticleRepository, sample_article_in_db: ArticleDTO):
|
||||
article = sample_article_in_db
|
||||
|
||||
deleted = await repository.delete_article(article.id)
|
||||
assert deleted is True
|
||||
|
||||
retrieved = await repository.get_by_id(article.id)
|
||||
assert retrieved is None
|
||||
|
||||
async def test_delete_nonexistent_article(self, repository: ArticleRepository):
|
||||
deleted = await repository.delete_article(99999)
|
||||
assert deleted is False
|
||||
|
||||
|
||||
class TestAsyncWriteQueue:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_stop(self):
|
||||
mock_repository = AsyncMock()
|
||||
queue = AsyncWriteQueue(mock_repository, max_batch_size=5)
|
||||
@pytest_asyncio.fixture
|
||||
async def write_queue(self, repository: ArticleRepository) -> AsyncWriteQueue:
|
||||
queue = AsyncWriteQueue(repository, max_batch_size=2)
|
||||
await queue.start()
|
||||
yield queue
|
||||
await queue.stop()
|
||||
|
||||
async def test_write_queue_startup_shutdown(self, repository: ArticleRepository):
|
||||
queue = AsyncWriteQueue(repository)
|
||||
|
||||
await queue.start()
|
||||
assert queue._worker_task is not None
|
||||
assert not queue._worker_task.done()
|
||||
|
||||
await queue.stop(timeout=1.0)
|
||||
await queue.stop()
|
||||
assert queue._worker_task.done()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_from_result_success(self, sample_article, simplified_text):
|
||||
mock_repository = AsyncMock()
|
||||
mock_repository.get_by_url.return_value = sample_article
|
||||
mock_repository.update_article.return_value = sample_article
|
||||
async def test_update_article_operation(self, write_queue: AsyncWriteQueue, sample_article_in_db: ArticleDTO):
|
||||
article = sample_article_in_db
|
||||
article.simplified_text = "Updated content"
|
||||
|
||||
queue = AsyncWriteQueue(mock_repository, max_batch_size=1)
|
||||
await queue.start()
|
||||
await write_queue.update_article(article)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
retrieved = await write_queue.repository.get_by_id(article.id)
|
||||
assert retrieved.simplified_text == "Updated content"
|
||||
|
||||
async def test_update_from_result_success(self, write_queue: AsyncWriteQueue, sample_article_in_db: ArticleDTO):
|
||||
article = sample_article_in_db
|
||||
|
||||
try:
|
||||
result = ProcessingResult.success_result(
|
||||
url=sample_article.url,
|
||||
title=sample_article.title,
|
||||
raw_text=sample_article.raw_text,
|
||||
simplified_text=simplified_text,
|
||||
url=article.url,
|
||||
title=article.title,
|
||||
raw_text=article.raw_text,
|
||||
simplified_text="Processed content",
|
||||
token_count_raw=100,
|
||||
token_count_simplified=50,
|
||||
processing_time_seconds=2.0,
|
||||
processing_time_seconds=1.0,
|
||||
)
|
||||
|
||||
updated_article = await queue.update_from_result(result)
|
||||
updated_article = await write_queue.update_from_result(result)
|
||||
|
||||
assert updated_article.simplified_text == simplified_text
|
||||
mock_repository.get_by_url.assert_called_once_with(sample_article.url)
|
||||
mock_repository.update_article.assert_called_once()
|
||||
assert updated_article.simplified_text == "Processed content"
|
||||
assert updated_article.status == ArticleStatus.SIMPLIFIED
|
||||
|
||||
finally:
|
||||
await queue.stop(timeout=1.0)
|
||||
async def test_update_from_result_failure(self, write_queue: AsyncWriteQueue, sample_article_in_db: ArticleDTO):
|
||||
article = sample_article_in_db
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_from_result_failure(self, sample_article):
|
||||
mock_repository = AsyncMock()
|
||||
mock_repository.get_by_url.return_value = sample_article
|
||||
mock_repository.update_article.return_value = sample_article
|
||||
|
||||
queue = AsyncWriteQueue(mock_repository, max_batch_size=1)
|
||||
await queue.start()
|
||||
|
||||
try:
|
||||
result = ProcessingResult.failure_result(
|
||||
url=sample_article.url,
|
||||
error_message="Тестовая ошибка",
|
||||
url=article.url,
|
||||
error_message="Processing failed",
|
||||
)
|
||||
|
||||
updated_article = await queue.update_from_result(result)
|
||||
updated_article = await write_queue.update_from_result(result)
|
||||
|
||||
assert updated_article.error_message == "Тестовая ошибка"
|
||||
mock_repository.update_article.assert_called_once()
|
||||
assert updated_article.status == ArticleStatus.FAILED
|
||||
|
||||
finally:
|
||||
await queue.stop(timeout=1.0)
|
||||
|
||||
def test_stats(self):
|
||||
mock_repository = AsyncMock()
|
||||
queue = AsyncWriteQueue(mock_repository)
|
||||
|
||||
stats = queue.stats
|
||||
async def test_queue_stats(self, write_queue: AsyncWriteQueue):
|
||||
stats = write_queue.stats
|
||||
|
||||
assert "total_operations" in stats
|
||||
assert "failed_operations" in stats
|
||||
assert "queue_size" in stats
|
||||
assert stats["total_operations"] == 0
|
||||
assert "success_rate" in stats
|
||||
|
||||
|
||||
class TestSimplifyService:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_adapters_and_queue(self, test_config):
|
||||
mock_ruwiki = AsyncMock(spec=RuWikiAdapter)
|
||||
mock_llm = AsyncMock(spec=LLMProviderAdapter)
|
||||
mock_repository = AsyncMock()
|
||||
mock_write_queue = AsyncMock()
|
||||
|
||||
return mock_ruwiki, mock_llm, mock_repository, mock_write_queue
|
||||
|
||||
def test_service_initialization(self, test_config, mock_adapters_and_queue):
|
||||
mock_ruwiki, mock_llm, mock_repository, mock_write_queue = mock_adapters_and_queue
|
||||
@pytest_asyncio.fixture
|
||||
async def simplify_service(self, test_config, repository: ArticleRepository) -> SimplifyService:
|
||||
ruwiki_adapter = AsyncMock()
|
||||
llm_adapter = AsyncMock()
|
||||
write_queue = AsyncMock()
|
||||
|
||||
service = SimplifyService(
|
||||
config=test_config,
|
||||
ruwiki_adapter=mock_ruwiki,
|
||||
llm_adapter=mock_llm,
|
||||
repository=mock_repository,
|
||||
write_queue=mock_write_queue,
|
||||
ruwiki_adapter=ruwiki_adapter,
|
||||
llm_adapter=llm_adapter,
|
||||
repository=repository,
|
||||
write_queue=write_queue,
|
||||
)
|
||||
|
||||
assert service.config == test_config
|
||||
assert service.ruwiki_adapter == mock_ruwiki
|
||||
assert service.llm_adapter == mock_llm
|
||||
assert isinstance(service.text_splitter, RecursiveCharacterTextSplitter)
|
||||
return service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prompt_template(self, test_config, mock_adapters_and_queue):
|
||||
mock_ruwiki, mock_llm, mock_repository, mock_write_queue = mock_adapters_and_queue
|
||||
async def test_get_prompt_template(self, simplify_service: SimplifyService):
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
with patch("pathlib.Path.read_text", return_value="Test prompt"):
|
||||
template = await simplify_service.get_prompt_template()
|
||||
assert template == "Test prompt"
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("### role: system\nТы помощник")
|
||||
temp_prompt_path = f.name
|
||||
|
||||
test_config.prompt_template_path = temp_prompt_path
|
||||
|
||||
service = SimplifyService(
|
||||
config=test_config,
|
||||
ruwiki_adapter=mock_ruwiki,
|
||||
llm_adapter=mock_llm,
|
||||
repository=mock_repository,
|
||||
write_queue=mock_write_queue,
|
||||
async def test_check_existing_article(self, simplify_service: SimplifyService):
|
||||
existing_article = ArticleDTO(
|
||||
id=1,
|
||||
url="https://ru.ruwiki.ru/wiki/Test",
|
||||
title="Test",
|
||||
raw_text="Content",
|
||||
simplified_text="Simplified",
|
||||
status=ArticleStatus.SIMPLIFIED,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
try:
|
||||
template = await service.get_prompt_template()
|
||||
assert "### role: system" in template
|
||||
assert "Ты помощник" in template
|
||||
simplify_service.repository.get_by_url = AsyncMock(return_value=existing_article)
|
||||
|
||||
template2 = await service.get_prompt_template()
|
||||
assert template == template2
|
||||
|
||||
finally:
|
||||
Path(temp_prompt_path).unlink(missing_ok=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prompt_template_not_found(self, test_config, mock_adapters_and_queue):
|
||||
mock_ruwiki, mock_llm, mock_repository, mock_write_queue = mock_adapters_and_queue
|
||||
|
||||
test_config.prompt_template_path = "nonexistent.txt"
|
||||
|
||||
service = SimplifyService(
|
||||
config=test_config,
|
||||
ruwiki_adapter=mock_ruwiki,
|
||||
llm_adapter=mock_llm,
|
||||
repository=mock_repository,
|
||||
write_queue=mock_write_queue,
|
||||
)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await service.get_prompt_template()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_command_success(
|
||||
self, test_config, mock_adapters_and_queue, sample_wikitext, simplified_text
|
||||
):
|
||||
mock_ruwiki, mock_llm, mock_repository, mock_write_queue = mock_adapters_and_queue
|
||||
|
||||
wiki_page_info = WikiPageInfo(
|
||||
title="Тест",
|
||||
content=sample_wikitext,
|
||||
)
|
||||
mock_ruwiki.fetch_page_cleaned.return_value = wiki_page_info
|
||||
mock_llm.simplify_text.return_value = (simplified_text, 100, 50)
|
||||
mock_llm.count_tokens.return_value = 100
|
||||
|
||||
mock_repository.get_by_url.return_value = None
|
||||
mock_repository.create_article.return_value = MagicMock(id=1)
|
||||
mock_repository.update_article.return_value = MagicMock()
|
||||
|
||||
mock_write_queue.update_from_result.return_value = MagicMock()
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("### role: user\n{wiki_source_text}")
|
||||
test_config.prompt_template_path = f.name
|
||||
|
||||
service = SimplifyService(
|
||||
config=test_config,
|
||||
ruwiki_adapter=mock_ruwiki,
|
||||
llm_adapter=mock_llm,
|
||||
repository=mock_repository,
|
||||
write_queue=mock_write_queue,
|
||||
)
|
||||
|
||||
try:
|
||||
command = SimplifyCommand(url="https://ru.wikipedia.org/wiki/Тест")
|
||||
result = await service.process_command(command)
|
||||
result = await simplify_service._check_existing_article("https://ru.ruwiki.ru/wiki/Test")
|
||||
|
||||
assert result is not None
|
||||
assert result.success is True
|
||||
assert result.title == "Тест"
|
||||
assert result.simplified_text == simplified_text
|
||||
assert result.token_count_raw == 100
|
||||
assert result.token_count_simplified == 50
|
||||
assert result.simplified_text == "Simplified"
|
||||
|
||||
mock_ruwiki.fetch_page_cleaned.assert_called_once()
|
||||
mock_llm.simplify_text.assert_called_once()
|
||||
mock_write_queue.update_from_result.assert_called_once()
|
||||
async def test_check_existing_article_not_found(self, simplify_service: SimplifyService):
|
||||
simplify_service.repository.get_by_url = AsyncMock(return_value=None)
|
||||
|
||||
finally:
|
||||
Path(test_config.prompt_template_path).unlink(missing_ok=True)
|
||||
result = await simplify_service._check_existing_article("https://ru.ruwiki.ru/wiki/Test")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_command_skip_existing(
|
||||
self, test_config, mock_adapters_and_queue, completed_article
|
||||
):
|
||||
mock_ruwiki, mock_llm, mock_repository, mock_write_queue = mock_adapters_and_queue
|
||||
assert result is None
|
||||
|
||||
mock_repository.get_by_url.return_value = completed_article
|
||||
async def test_health_check(self, simplify_service: SimplifyService):
|
||||
simplify_service.ruwiki_adapter.health_check = AsyncMock()
|
||||
simplify_service.llm_adapter.health_check = AsyncMock()
|
||||
|
||||
service = SimplifyService(
|
||||
config=test_config,
|
||||
ruwiki_adapter=mock_ruwiki,
|
||||
llm_adapter=mock_llm,
|
||||
repository=mock_repository,
|
||||
write_queue=mock_write_queue,
|
||||
)
|
||||
checks = await simplify_service.health_check()
|
||||
|
||||
command = SimplifyCommand(url=completed_article.url, force_reprocess=False)
|
||||
result = await service.process_command(command)
|
||||
|
||||
assert result.success is True
|
||||
assert result.title == completed_article.title
|
||||
|
||||
mock_ruwiki.fetch_page_cleaned.assert_not_called()
|
||||
mock_llm.simplify_text.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(self, test_config, mock_adapters_and_queue):
|
||||
mock_ruwiki, mock_llm, mock_repository, mock_write_queue = mock_adapters_and_queue
|
||||
|
||||
mock_ruwiki.health_check.return_value = True
|
||||
mock_llm.health_check.return_value = True
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("test prompt")
|
||||
test_config.prompt_template_path = f.name
|
||||
|
||||
service = SimplifyService(
|
||||
config=test_config,
|
||||
ruwiki_adapter=mock_ruwiki,
|
||||
llm_adapter=mock_llm,
|
||||
repository=mock_repository,
|
||||
write_queue=mock_write_queue,
|
||||
)
|
||||
|
||||
try:
|
||||
checks = await service.health_check()
|
||||
|
||||
assert checks["ruwiki"] is True
|
||||
assert checks["llm"] is True
|
||||
assert checks["prompt_template"] is True
|
||||
|
||||
finally:
|
||||
Path(test_config.prompt_template_path).unlink(missing_ok=True)
|
||||
assert "ruwiki" in checks
|
||||
assert "llm" in checks
|
||||
assert "prompt_template" in checks
|
||||
|
|
Loading…
Reference in New Issue