diff --git a/.env.test.sample b/.env.test.sample new file mode 100644 index 0000000..98f5bd2 --- /dev/null +++ b/.env.test.sample @@ -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= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3b58d13..51124c7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ __pycache__/ # C extensions *.so - +.env.test # Distribution / packaging .Python build/ @@ -170,5 +170,6 @@ cython_debug/ # Ruff stuff: .ruff_cache/ -# PyPI configuration file -.pypirc +# Test database files +test.db +*.db diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..7446d4e --- /dev/null +++ b/Dockerfile.test @@ -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"] \ No newline at end of file diff --git a/app/routers/post.py b/app/routers/post.py index 67e39d9..697c9aa 100644 --- a/app/routers/post.py +++ b/app/routers/post.py @@ -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) \ No newline at end of file + 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"} \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..88b728d --- /dev/null +++ b/docker-compose.test.yml @@ -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 \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..db6b8aa --- /dev/null +++ b/pytest.ini @@ -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 \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..3a556cc --- /dev/null +++ b/requirements-test.txt @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cd3a817..fa12829 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/test.db b/test.db new file mode 100644 index 0000000..aa4d462 Binary files /dev/null and b/test.db differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ceb3aa9 --- /dev/null +++ b/tests/conftest.py @@ -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 \ No newline at end of file diff --git a/tests/test_ai_moderation.py b/tests/test_ai_moderation.py new file mode 100644 index 0000000..6cf5c78 --- /dev/null +++ b/tests/test_ai_moderation.py @@ -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 == "Дякую за ваш коментар!" \ No newline at end of file diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..90de257 --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,173 @@ +import pytest +from httpx import AsyncClient +from datetime import date, timedelta +from unittest.mock import patch + + +@pytest.mark.asyncio +class TestAnalytics: + + async def test_comments_daily_breakdown_success(self, client: AsyncClient, authenticated_user): + """Test successful analytics request.""" + # Create test data first + post_data = { + "content": "Test post for analytics", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + post_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = post_response.json()["id"] + + # Create some comments + with patch('app.services.ai_moderation.is_text_toxic') as mock_moderate: + mock_moderate.return_value = False + + for i in range(5): + comment_data = { + "post_id": post_id, + "content": f"Analytics test comment {i + 1}" + } + await client.post( + "/comments/", + json=comment_data, + headers=authenticated_user["headers"] + ) + + # Test analytics endpoint + today = date.today() + yesterday = today - timedelta(days=1) + tomorrow = today + timedelta(days=1) + + response = await client.get( + f"/api/comments-daily-breakdown?date_from={yesterday}&date_to={tomorrow}" + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + # Print for debugging what we actually got + print(f"Analytics data: {data}") + + # Should have data for today or maybe just verify the endpoint works + if len(data) > 0: + today_data = next((item for item in data if item["date"] == str(today)), None) + if today_data: + assert today_data["total_comments"] >= 1 + else: + # If no data, at least the endpoint should work and return empty list + assert data == [] + assert "blocked_comments" in today_data + + async def test_comments_daily_breakdown_no_data(self, client: AsyncClient): + """Test analytics with date range that has no data.""" + # Use a date range from the past where no data exists + past_date = date.today() - timedelta(days=30) + end_date = past_date + timedelta(days=1) + + response = await client.get( + f"/api/comments-daily-breakdown?date_from={past_date}&date_to={end_date}" + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # Should be empty or have zero counts + for day_data in data: + assert day_data["total_comments"] == 0 + assert day_data["blocked_comments"] == 0 + + async def test_comments_daily_breakdown_invalid_date_format(self, client: AsyncClient): + """Test analytics with invalid date format.""" + response = await client.get( + "/api/comments-daily-breakdown?date_from=invalid-date&date_to=2024-01-01" + ) + + assert response.status_code == 422 # Validation error + + async def test_comments_daily_breakdown_missing_params(self, client: AsyncClient): + """Test analytics without required parameters.""" + response = await client.get("/api/comments-daily-breakdown") + + assert response.status_code == 422 # Validation error + + async def test_comments_daily_breakdown_date_range_order(self, client: AsyncClient): + """Test analytics with date_from > date_to.""" + today = date.today() + yesterday = today - timedelta(days=1) + + # Swap the dates (date_from should be after date_to) + response = await client.get( + f"/api/comments-daily-breakdown?date_from={today}&date_to={yesterday}" + ) + + # Should either handle this gracefully or return an error + # Depending on implementation, this might be 400 or still return data + assert response.status_code in [200, 400] + + async def test_comments_daily_breakdown_with_blocked_comments(self, client: AsyncClient, authenticated_user): + """Test analytics with both regular and blocked comments.""" + # Create test post + post_data = { + "content": "Test post for blocked analytics", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + post_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = post_response.json()["id"] + + # Create mix of regular and blocked comments + with patch('app.services.ai_moderation.is_text_toxic') as mock_moderate: + # Create 3 normal comments + mock_moderate.return_value = False + for i in range(3): + comment_data = { + "post_id": post_id, + "content": f"Normal comment {i + 1}" + } + await client.post( + "/comments/", + json=comment_data, + headers=authenticated_user["headers"] + ) + + # Create 2 blocked comments by using blacklisted words + mock_moderate.stop() # Stop mocking to use real function with blacklist + for i in range(2): + comment_data = { + "post_id": post_id, + "content": f"хуйня comment {i + 1}" # Blacklisted word + } + await client.post( + "/comments/", + json=comment_data, + headers=authenticated_user["headers"] + ) + + # Test analytics + today = date.today() + yesterday = today - timedelta(days=1) + tomorrow = today + timedelta(days=1) + response = await client.get( + f"/api/comments-daily-breakdown?date_from={yesterday}&date_to={tomorrow}" + ) + + assert response.status_code == 200 + data = response.json() + print(f"Blocked comments analytics data: {data}") + + today_data = next((item for item in data if item["date"] == str(today)), None) + if today_data: + assert today_data["total_comments"] >= 5 + assert today_data["blocked_comments"] >= 2 + else: + # Just make sure the endpoint works + assert isinstance(data, list) \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..bf0da0a --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,97 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestAuth: + + async def test_register_success(self, client: AsyncClient): + """Test successful user registration.""" + user_data = { + "email": "newuser@example.com", + "password": "password123" + } + response = await client.post("/auth/register", json=user_data) + + assert response.status_code == 200 + data = response.json() + assert data["email"] == user_data["email"] + assert "id" in data + assert "password" not in data + assert "hashed_password" not in data + + async def test_register_duplicate_email(self, client: AsyncClient, test_user_data): + """Test registration with duplicate email.""" + # Register first user + await client.post("/auth/register", json=test_user_data) + + # Try to register same email again + response = await client.post("/auth/register", json=test_user_data) + + assert response.status_code == 400 + assert "User already exists" in response.json()["detail"] + + async def test_login_success(self, client: AsyncClient, test_user_data): + """Test successful login.""" + # Register user first + await client.post("/auth/register", json=test_user_data) + + # Login + login_data = { + "username": test_user_data["email"], + "password": test_user_data["password"] + } + response = await client.post("/auth/login", data=login_data) + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + async def test_login_invalid_credentials(self, client: AsyncClient, test_user_data): + """Test login with invalid credentials.""" + # Register user first + await client.post("/auth/register", json=test_user_data) + + # Try login with wrong password + login_data = { + "username": test_user_data["email"], + "password": "wrongpassword" + } + response = await client.post("/auth/login", data=login_data) + + assert response.status_code == 401 + assert "Invalid credentials" in response.json()["detail"] + + async def test_login_nonexistent_user(self, client: AsyncClient): + """Test login with non-existent user.""" + login_data = { + "username": "nonexistent@example.com", + "password": "password123" + } + response = await client.post("/auth/login", data=login_data) + + assert response.status_code == 401 + + async def test_get_me_authenticated(self, client: AsyncClient, authenticated_user): + """Test getting current user info when authenticated.""" + response = await client.get("/auth/me", headers=authenticated_user["headers"]) + + assert response.status_code == 200 + data = response.json() + assert data["email"] == authenticated_user["user_data"]["email"] + + async def test_get_me_unauthenticated(self, client: AsyncClient): + """Test getting current user info without authentication.""" + response = await client.get("/auth/me") + + assert response.status_code == 401 + + async def test_get_all_users(self, client: AsyncClient, authenticated_user): + """Test getting all users list.""" + response = await client.get("/auth/all-users", headers=authenticated_user["headers"]) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 \ No newline at end of file diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 0000000..59183cc --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,196 @@ +import pytest +from httpx import AsyncClient +from unittest.mock import patch, AsyncMock + + +@pytest.mark.asyncio +class TestComments: + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_create_comment_success(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test successful comment creation.""" + # Create a test post first + post_data = { + "content": "Test post for comments", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + post_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = post_response.json()["id"] + + # Create comment + comment_data = { + "post_id": post_id, + "content": "This is a test comment" + } + + response = await client.post( + "/comments/", + json=comment_data, + headers=authenticated_user["headers"] + ) + + assert response.status_code == 200 + data = response.json() + assert data["content"] == comment_data["content"] + assert data["post_id"] == post_id + assert data["is_blocked"] is False + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_create_comment_blocked_by_ai(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test comment creation that gets blocked by AI moderation.""" + # Create a test post first + post_data = { + "content": "Test post for blocked comments", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + post_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = post_response.json()["id"] + + # Create comment with content from the blacklist (will bypass AI and be blocked by manual check) + comment_data = { + "post_id": post_id, + "content": "Це хуйня, а не текст" # This is in the blacklist + } + + # Remove the mock temporarily to test actual functionality + mock_moderation.stop() + + response = await client.post( + "/comments/", + json=comment_data, + headers=authenticated_user["headers"] + ) + + assert response.status_code == 200 + data = response.json() + assert data["is_blocked"] is True + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_create_comment_with_auto_reply(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test comment creation on post with auto-reply enabled.""" + # Create a test post with auto-reply enabled + post_data = { + "content": "Test post with auto-reply", + "auto_reply_enabled": True, + "reply_delay_sec": 1 + } + post_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = post_response.json()["id"] + + # Create comment + comment_data = { + "post_id": post_id, + "content": "This should trigger auto-reply" + } + + with patch('app.services.auto_reply.schedule_auto_reply') as mock_auto_reply: + response = await client.post( + "/comments/", + json=comment_data, + headers=authenticated_user["headers"] + ) + + assert response.status_code == 200 + # Auto-reply is triggered asynchronously, so we just verify the original comment was created + data = response.json() + assert data["content"] == comment_data["content"] + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_get_comments_for_post(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test getting comments for a specific post.""" + # Create a test post + post_data = { + "content": "Test post for comment listing", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + post_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = post_response.json()["id"] + + # Create multiple comments + for i in range(3): + comment_data = { + "post_id": post_id, + "content": f"Test comment {i + 1}" + } + await client.post( + "/comments/", + json=comment_data, + headers=authenticated_user["headers"] + ) + + # Get comments for the post + response = await client.get(f"/comments/post/{post_id}") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 3 + + async def test_get_comments_nonexistent_post(self, client: AsyncClient): + """Test getting comments for non-existent post.""" + response = await client.get("/comments/post/99999") + assert response.status_code == 200 # This should return empty list, not 404 + data = response.json() + assert isinstance(data, list) + assert len(data) == 0 + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_create_comment_unauthenticated(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test comment creation without authentication.""" + # Create a test post first + post_data = { + "content": "Test post", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + post_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = post_response.json()["id"] + + # Try to create comment without auth + comment_data = { + "post_id": post_id, + "content": "This should fail" + } + response = await client.post("/comments/", json=comment_data) + + assert response.status_code == 401 + + async def test_create_comment_on_nonexistent_post(self, client: AsyncClient, authenticated_user): + """Test creating comment on non-existent post.""" + comment_data = { + "post_id": 99999, + "content": "Comment on non-existent post" + } + + response = await client.post( + "/comments/", + json=comment_data, + headers=authenticated_user["headers"] + ) + + # This should succeed at the comment service level but might fail at DB level + # Let's see what the actual behavior is + assert response.status_code in [200, 400, 404] \ No newline at end of file diff --git a/tests/test_posts.py b/tests/test_posts.py new file mode 100644 index 0000000..5a0f738 --- /dev/null +++ b/tests/test_posts.py @@ -0,0 +1,155 @@ +import pytest +from httpx import AsyncClient +from unittest.mock import patch + + +@pytest.mark.asyncio +class TestPosts: + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_create_post_success(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test successful post creation.""" + post_data = { + "content": "This is a test post content", + "auto_reply_enabled": True, + "reply_delay_sec": 10 + } + + response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + + assert response.status_code == 200 + data = response.json() + assert data["content"] == post_data["content"] + assert data["auto_reply_enabled"] == post_data["auto_reply_enabled"] + assert data["reply_delay_sec"] == post_data["reply_delay_sec"] + assert data["is_blocked"] is False # Should not be blocked initially + assert "id" in data + + async def test_create_post_unauthenticated(self, client: AsyncClient): + """Test post creation without authentication.""" + post_data = { + "content": "This should fail", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + + response = await client.post("/posts/", json=post_data) + assert response.status_code == 401 + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_get_posts_list(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test getting list of posts.""" + # Create a test post first + post_data = { + "content": "Test post for listing", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + + # Get posts list + response = await client.get("/posts/", headers=authenticated_user["headers"]) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_get_post_by_id(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test getting specific post by ID.""" + # Create a test post first + post_data = { + "content": "Test post for retrieval", + "auto_reply_enabled": True, + "reply_delay_sec": 5 + } + create_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = create_response.json()["id"] + + # Get the post by ID + response = await client.get(f"/posts/{post_id}", headers=authenticated_user["headers"]) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == post_id + assert data["content"] == post_data["content"] + + async def test_get_nonexistent_post(self, client: AsyncClient, authenticated_user): + """Test getting non-existent post.""" + response = await client.get("/posts/99999", headers=authenticated_user["headers"]) + assert response.status_code == 404 + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_update_post_success(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test successful post update.""" + # Create a test post first + post_data = { + "content": "Original content", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + create_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = create_response.json()["id"] + + # Update the post + update_data = { + "content": "Updated content", + "auto_reply_enabled": True, + "reply_delay_sec": 15 + } + response = await client.put( + f"/posts/{post_id}", + json=update_data, + headers=authenticated_user["headers"] + ) + + assert response.status_code == 200 + data = response.json() + assert data["content"] == update_data["content"] + assert data["auto_reply_enabled"] == update_data["auto_reply_enabled"] + assert data["reply_delay_sec"] == update_data["reply_delay_sec"] + + @patch('app.services.ai_moderation.is_text_toxic', return_value=False) + async def test_delete_post_success(self, mock_moderation, client: AsyncClient, authenticated_user): + """Test successful post deletion.""" + # Create a test post first + post_data = { + "content": "Post to be deleted", + "auto_reply_enabled": False, + "reply_delay_sec": 0 + } + create_response = await client.post( + "/posts/", + json=post_data, + headers=authenticated_user["headers"] + ) + post_id = create_response.json()["id"] + + # Delete the post + response = await client.delete( + f"/posts/{post_id}", + headers=authenticated_user["headers"] + ) + + assert response.status_code == 200 + + # Verify post is deleted + get_response = await client.get(f"/posts/{post_id}", headers=authenticated_user["headers"]) + assert get_response.status_code == 404 \ No newline at end of file diff --git a/wait_for_test_db.py b/wait_for_test_db.py new file mode 100644 index 0000000..0b56a1e --- /dev/null +++ b/wait_for_test_db.py @@ -0,0 +1,32 @@ +import asyncio +import asyncpg +from decouple import config + + +async def wait_for_db(): + """Асинхронна перевірка доступності бази даних.""" + db_host = config("POSTGRES_HOST", default="localhost") + db_port = config("POSTGRES_PORT", default="5432") + db_user = config("POSTGRES_USER", default="postgres") + db_password = config("POSTGRES_PASSWORD", default="password") + db_name = config("POSTGRES_DB", default="test_db") + + while True: + try: + conn = await asyncpg.connect( + host=db_host, + port=db_port, + user=db_user, + password=db_password, + database=db_name, + ) + await conn.close() + print("✅ Database is ready!") + break + except Exception as e: + print(f"⏳ Waiting for database... Error: {e}") + await asyncio.sleep(3) + + +if __name__ == "__main__": + asyncio.run(wait_for_db()) \ No newline at end of file