Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.test.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Тестова база даних
POSTGRES_PASSWORD=
POSTGRES_HOST=
POSTGRES_USER=
POSTGRES_DB=
POSTGRES_PORT=
TEST_DATABASE_URL=

# Безпека JWT для тестів
TEST_SECRET_KEY=
TEST_ALGORITHM=
TEST_ACCESS_TOKEN_EXPIRE_MINUTES=

# Google AI API для тестового середовища
TEST_GOOGLE_API_KEY=
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ __pycache__/

# C extensions
*.so

.env.test
# Distribution / packaging
.Python
build/
Expand Down Expand Up @@ -170,5 +170,6 @@ cython_debug/
# Ruff stuff:
.ruff_cache/

# PyPI configuration file
.pypirc
# Test database files
test.db
*.db
20 changes: 20 additions & 0 deletions Dockerfile.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM python:3.11-slim

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY requirements.txt requirements-test.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-test.txt

# Copy application code
COPY . .

# Default command
CMD ["pytest", "tests/", "-v", "--asyncio-mode=auto"]
20 changes: 19 additions & 1 deletion app/routers/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,22 @@ async def get_post_by_id(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
return await get_post(post_id, user.id, db)
return await get_post(post_id, user.id, db)

@router.put("/{post_id}", response_model=PostRead)
async def update_post_by_id(
post_id: int,
post_in: PostCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
return await update_post(post_id, user.id, post_in, db)

@router.delete("/{post_id}")
async def delete_post_by_id(
post_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
await delete_post(post_id, user.id, db)
return {"message": "Post deleted successfully"}
47 changes: 47 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@


services:
db-test:
image: postgres:15-alpine
env_file:
- .env.test
ports:
- "5432:5432"
volumes:
- test_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB} -h localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- test-network

app-test:
build:
context: .
dockerfile: Dockerfile.test
env_file:
- .env.test
volumes:
- .:/app
working_dir: /app
command: >
sh -c "
echo '🔄 Waiting for test database...' &&
python wait_for_test_db.py &&
echo '🧪 Running tests...' &&
pytest tests/ -v --tb=short --asyncio-mode=auto
"
depends_on:
db-test:
condition: service_healthy
networks:
- test-network

volumes:
test_db_data:

networks:
test-network:
driver: bridge
8 changes: 8 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[tool:pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
6 changes: 6 additions & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pytest==8.3.5
pytest-asyncio==0.26.0
pytest-cov
httpx==0.28.1
asyncpg==0.30.0
python-decouple==3.8
Binary file modified requirements.txt
Binary file not shown.
Binary file added test.db
Binary file not shown.
127 changes: 127 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import pytest
import pytest_asyncio
import asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from app.main import app
from app.core.db import get_db, Base
from app.models.user import User # Приклад для створення тестового користувача
from decouple import Config, RepositoryEnv
from unittest.mock import patch
from app.core.security import hash_password, create_access_token

# Завантаження змінних середовища з .env.test
env_config = Config(RepositoryEnv(".env.test"))

# Отримання TEST_DATABASE_URL
TEST_DATABASE_URL = env_config(
"TEST_DATABASE_URL",
default="postgresql+asyncpg://test_user:test_password@localhost:5433/test_blogdb"
)

if not TEST_DATABASE_URL:
raise Exception("❌ TEST_DATABASE_URL is not set or is empty!")

print(f"🔍 [TESTS] Using database: {TEST_DATABASE_URL}")

# Створення двигуна для тестової бази даних
test_engine = create_async_engine(
TEST_DATABASE_URL,
poolclass=NullPool,
echo=False, # Встановіть True для дебагінгу SQL-запитів
future=True
)

# Конфігурація сесії SQLAlchemy
TestingSessionLocal = sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False
)


@pytest_asyncio.fixture(scope="session")
async def setup_test_db():
"""Підготовка бази даних для тестування (один раз за сесію)."""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await test_engine.dispose()


@pytest_asyncio.fixture
async def db_session(setup_test_db):
"""Чиста сесія бази даних для кожного тесту."""
# Create a fresh session for each test
async with TestingSessionLocal() as session:
yield session
# After test - manually clean up all tables
await session.rollback()
# Delete all data from tables
for table in reversed(Base.metadata.sorted_tables):
await session.execute(table.delete())
await session.commit()


@pytest_asyncio.fixture
async def client(db_session):
"""Тестовий клієнт із перевизначеною залежністю бази даних."""

async def override_get_db():
yield db_session

app.dependency_overrides[get_db] = override_get_db

async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac

app.dependency_overrides.clear()


@pytest.fixture
def test_user_data():
"""Фікстура з даними тестового користувача."""
return {
"email": "test@example.com",
"password": "password123"
}


@pytest_asyncio.fixture
async def test_user(db_session, test_user_data):
"""Фікстура для створення тестового користувача."""
user = User(
email=test_user_data["email"],
hashed_password=hash_password(test_user_data["password"])
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user


@pytest_asyncio.fixture
async def authenticated_user(client, test_user, test_user_data):
"""Фікстура для аутентифікованого користувача."""
# Створити JWT токен для тестового користувача
access_token = create_access_token(data={"sub": test_user.email})
headers = {"Authorization": f"Bearer {access_token}"}

return {
"user": test_user,
"user_data": test_user_data,
"headers": headers,
"token": access_token
}


@pytest.fixture
def mock_google_ai():
"""Фікстура для мокування Google AI API."""
with patch("app.services.ai_moderation.client.models.generate_content") as mock:
yield mock
77 changes: 77 additions & 0 deletions tests/test_ai_moderation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import pytest
from unittest.mock import patch, MagicMock
from app.services.ai_moderation import is_text_toxic, generate_reply


class TestAIModeration:
@patch("app.services.ai_moderation.client.models.generate_content")
def test_is_text_toxic_manual_detection(self, mock_generate_content):
# Тест перевіряє ручну перевірку чорного списку
toxic_text = "Це хуйня, а не текст."
result = is_text_toxic(toxic_text)
assert result is True

@patch("app.services.ai_moderation.client.models.generate_content")
def test_is_text_toxic_ai_detection_yes(self, mock_generate_content):
# Мокаємо відповідь від AI як "YES"
mock_response = MagicMock()
mock_response.text = "YES"
mock_generate_content.return_value = mock_response

non_toxic_text = "Цей текст потенційно токсичний."
result = is_text_toxic(non_toxic_text)

mock_generate_content.assert_called_once()
assert result is True

@patch("app.services.ai_moderation.client.models.generate_content")
def test_is_text_toxic_ai_detection_no(self, mock_generate_content):
# Мокаємо відповідь від AI як "NO"
mock_response = MagicMock()
mock_response.text = "NO"
mock_generate_content.return_value = mock_response

non_toxic_text = "Звичайний текст без образ."
result = is_text_toxic(non_toxic_text)

mock_generate_content.assert_called_once()
assert result is False

@patch("app.services.ai_moderation.client.models.generate_content")
def test_is_text_toxic_ai_error(self, mock_generate_content):
# Симулюємо помилку при виклику AI
mock_generate_content.side_effect = Exception("AI error")

text = "Текст для перевірки."
result = is_text_toxic(text)

mock_generate_content.assert_called_once()
assert result is False

@patch("app.services.ai_moderation.client.models.generate_content")
def test_generate_reply_success(self, mock_generate_content):
# Мокаємо успішну відповідь від AI
mock_response = MagicMock()
mock_response.text = "Дякую за ваш коментар!"
mock_generate_content.return_value = mock_response

post_text = "Ось мій пост."
comment_text = "Це цікавий коментар."

reply = generate_reply(post_text, comment_text)

mock_generate_content.assert_called_once()
assert reply == "Дякую за ваш коментар!"

@patch("app.services.ai_moderation.client.models.generate_content")
def test_generate_reply_error(self, mock_generate_content):
# Симулюємо помилку при виклику AI
mock_generate_content.side_effect = Exception("AI error")

post_text = "Ось мій пост."
comment_text = "Це цікавий коментар."

reply = generate_reply(post_text, comment_text)

mock_generate_content.assert_called_once()
assert reply == "Дякую за ваш коментар!"
Loading