diff --git a/Makefile b/Makefile index bfef2f7..fb6da07 100755 --- a/Makefile +++ b/Makefile @@ -1,3 +1,4 @@ +API_CONTAINER_NAME=pynews-server .PHONY: help build up down logs test lint format clean dev prod restart health # Colors for terminal output @@ -73,3 +74,7 @@ shell: ## Entra no shell do container setup: install build up ## Setup completo do projeto @echo "$(GREEN)Setup completo realizado!$(NC)" @echo "$(GREEN)Acesse: http://localhost:8000/docs$(NC)" + + +docker/test: + docker exec -e PYTHONPATH=/app $(API_CONTAINER_NAME) pytest -s --cov-report=term-missing --cov-report html --cov-report=xml --cov=app tests/ diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 29d3945..311b058 100755 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -14,6 +14,44 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/authentication/token") +async def get_current_community( + request: Request, + token: Annotated[str, Depends(oauth2_scheme)], +) -> DBCommunity: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, auth.SECRET_KEY, algorithms=[auth.ALGORITHM] + ) + username = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenPayload(username=username) + except InvalidTokenError: + raise credentials_exception + session: AsyncSession = request.app.db_session_factory + community = await get_community_by_username( + session=session, username=token_data.username + ) + if community is None: + raise credentials_exception + + return community + + +async def get_current_active_community( + current_user: Annotated[DBCommunity, Depends(get_current_community)], +) -> DBCommunity: + # A função simplesmente retorna o usuário. + # Pode ser estendido futuramente para verificar um status "ativo". + return current_user + + def setup(): router = APIRouter(prefix="/authentication", tags=["authentication"]) @@ -31,43 +69,6 @@ async def authenticate_community( return None return found_community - # Teste - async def get_current_community( - request: Request, - token: Annotated[str, Depends(oauth2_scheme)], - ) -> DBCommunity: - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - try: - payload = jwt.decode( - token, auth.SECRET_KEY, algorithms=[auth.ALGORITHM] - ) - username = payload.get("sub") - if username is None: - raise credentials_exception - token_data = TokenPayload(username=username) - except InvalidTokenError: - raise credentials_exception - session: AsyncSession = request.app.db_session_factory - community = await get_community_by_username( - session=session, username=token_data.username - ) - if community is None: - raise credentials_exception - - return community - - async def get_current_active_community( - current_user: Annotated[DBCommunity, Depends(get_current_community)], - ) -> DBCommunity: - # A função simplesmente retorna o usuário. - # Pode ser estendido futuramente para verificar um status "ativo". - return current_user - # Teste @router.post("/create_commumity") diff --git a/app/routers/news/routes.py b/app/routers/news/routes.py index 5869a45..cad6a96 100644 --- a/app/routers/news/routes.py +++ b/app/routers/news/routes.py @@ -1,6 +1,13 @@ -from fastapi import APIRouter, Request, status +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, status +from fastapi.params import Header from pydantic import BaseModel -from services.database.orm.news import get_news_by_query_params + +from app.routers.authentication import get_current_active_community +from app.schemas import News +from app.services.database.models import Community as DBCommunity +from app.services.database.orm.news import create_news, get_news_by_query_params class NewsPostResponse(BaseModel): @@ -22,10 +29,22 @@ def setup(): summary="News endpoint", description="Creates news and returns a confirmation message", ) - async def post_news(): + async def post_news( + request: Request, + current_community: Annotated[ + DBCommunity, Depends(get_current_active_community) + ], + news: News, + user_email: str = Header(..., alias="user-email"), + ): """ News endpoint that creates news and returns a confirmation message. """ + news_dict = news.__dict__ + news_dict["user_email"] = user_email + await create_news( + session=request.app.db_session_factory, news=news_dict + ) return NewsPostResponse() @router.get( @@ -35,16 +54,25 @@ async def post_news(): summary="Get News", description="Retrieves news filtered by user and query params", ) - async def get_news(request: Request): + async def get_news( + request: Request, + current_community: Annotated[ + DBCommunity, Depends(get_current_active_community) + ], + id: str | None = None, + user_email: str = Header(..., alias="user-email"), + category: str | None = None, + tags: str | None = None, + ): """ Get News endpoint that retrieves news filtered by user and query params. """ news_list = await get_news_by_query_params( session=request.app.db_session_factory, - id=request.query_params.get("id"), - user_email=request.headers.get("user-email"), - category=request.query_params.get("category"), - tags=request.query_params.get("tags"), + id=id, + email=user_email, + category=category, + tags=tags, ) return NewsGetResponse(news_list=news_list) diff --git a/app/schemas.py b/app/schemas.py index 0d46056..60602d9 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -21,6 +21,16 @@ class CommunityInDB(Community): password: str +class News(BaseModel): + title: str + content: str + category: str + tags: str | None = None + source_url: str + social_media_url: str | None = None + likes: int = 0 + + class Token(BaseModel): access_token: str token_type: str diff --git a/app/services/database/models/subscriptions.py b/app/services/database/models/subscriptions.py index 4311923..575556e 100644 --- a/app/services/database/models/subscriptions.py +++ b/app/services/database/models/subscriptions.py @@ -1,9 +1,10 @@ from typing import List, Optional -from schemas import SubscriptionTagEnum from sqlalchemy import JSON, Column from sqlmodel import Field, SQLModel +from app.schemas import SubscriptionTagEnum + class Subscription(SQLModel, table=True): __tablename__ = "subscriptions" # type: ignore diff --git a/app/services/database/orm/news.py b/app/services/database/orm/news.py index ac73f9f..a02846f 100644 --- a/app/services/database/orm/news.py +++ b/app/services/database/orm/news.py @@ -1,20 +1,37 @@ from typing import Optional -from services.database.models import News from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from app.services.database.models import News + + +async def create_news(session: AsyncSession, news: dict) -> None: + _news = News( + title=news["title"], + content=news["content"], + category=news["category"], + user_email=news["user_email"], + source_url=news["source_url"], + tags=news["tags"] or "", + social_media_url=news["social_media_url"] or "", + likes=news["likes"], + ) + session.add(_news) + await session.commit() + await session.refresh(_news) + async def get_news_by_query_params( session: AsyncSession, - user_email: Optional[str] = None, + email: Optional[str] = None, category: Optional[str] = None, tags: Optional[str] = None, id: Optional[str] = None, ) -> list[News]: filters = [] - if user_email is not None: - filters.append(News.user_email == user_email) + if email is not None: + filters.append(News.user_email == email) if category is not None: filters.append(News.category == category) if tags is not None: @@ -22,9 +39,6 @@ async def get_news_by_query_params( if id is not None: filters.append(News.id == id) - print("user_email:", user_email) - print("Filters:", filters) - statement = select(News).where(*filters) results = await session.exec(statement) return results.all() diff --git a/tests/conftest.py b/tests/conftest.py index 358c92b..47b3584 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,13 +2,15 @@ import pytest import pytest_asyncio -from fastapi import FastAPI +from fastapi import FastAPI, status from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlmodel import SQLModel from sqlmodel.ext.asyncio.session import AsyncSession from app.main import app +from app.services.auth import hash_password +from app.services.database.models.communities import Community # from app.main import get_db_session @@ -73,8 +75,46 @@ async def async_client(test_app: FastAPI) -> AsyncGenerator[AsyncClient, None]: yield client +class CommunityCredentials: + username: str = "community_username" + email: str = "community_name@test.com" + password: str = "community_password" + hashed_password: str = hash_password(password) + + +@pytest_asyncio.fixture +async def community(session: AsyncSession): + community = Community( + username=CommunityCredentials.username, + email=CommunityCredentials.email, + password=CommunityCredentials.hashed_password, + ) + session.add(community) + await session.commit() + await session.refresh(community) + return community + + +@pytest_asyncio.fixture() +async def token(async_client: AsyncGenerator[AsyncClient, None]) -> str: + form_data = { + "grant_type": "password", + "username": CommunityCredentials.username, + "password": CommunityCredentials.password, + } + token_response = await async_client.post( + "/api/authentication/token", + data=form_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert token_response.status_code == status.HTTP_200_OK + return token_response.json()["access_token"] + + @pytest.fixture -def mock_headers(): +def valid_auth_headers(community: Community, token: str) -> dict[str, str]: return { - "header1": "value1", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "user-email": CommunityCredentials.email, } diff --git a/tests/test_auth.py b/tests/test_auth.py index 660b939..a0f713f 100755 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,26 +1,9 @@ import pytest -import pytest_asyncio from fastapi import status from httpx import AsyncClient -from services.database.models import Community -from sqlmodel.ext.asyncio.session import AsyncSession -from app.services.auth import hash_password - -password = "123Asd!@#" - - -# gerar usuario para autenticação -@pytest_asyncio.fixture -async def community(session: AsyncSession): - hashed_password = hash_password(password) - community = Community( - username="username", email="username@test.com", password=hashed_password - ) - session.add(community) - await session.commit() - await session.refresh(community) - return community +from app.services.database.models import Community +from tests.conftest import CommunityCredentials @pytest.mark.asyncio @@ -33,7 +16,10 @@ async def test_authentication_token_endpoint( """ # 1. Teste de login com credenciais válidas # O OAuth2PasswordRequestForm espera 'username' e 'password' - form_data = {"username": community.username, "password": password} + form_data = { + "username": community.username, + "password": CommunityCredentials.password, + } response = await async_client.post( "/api/authentication/token", @@ -66,16 +52,18 @@ async def test_authentication_token_endpoint( @pytest.mark.asyncio async def test_community_me_with_valid_token( - async_client: AsyncClient, community: Community + async_client: AsyncClient, + community: Community, ): """ - Testa se o endpoint protegido /authenticate/me/ retorna os dados do usuário com um token válido. + Testa se o endpoint protegido /authenticate/me/ retorna os dados do usuário + com um token válido. """ # 1. Obter um token de acesso primeiro form_data = { "grant_type": "password", "username": community.username, - "password": password, + "password": CommunityCredentials.password, } token_response = await async_client.post( "/api/authentication/token", diff --git a/tests/test_communities.py b/tests/test_communities.py index b858418..2894dce 100644 --- a/tests/test_communities.py +++ b/tests/test_communities.py @@ -1,8 +1,9 @@ import pytest -from services.database.models import Community from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from app.services.database.models import Community + @pytest.mark.asyncio async def test_insert_communities(session: AsyncSession): diff --git a/tests/test_healthcheck.py b/tests/test_healthcheck.py index 6b02d89..8623d74 100755 --- a/tests/test_healthcheck.py +++ b/tests/test_healthcheck.py @@ -7,11 +7,15 @@ @pytest.mark.asyncio async def test_healthcheck_endpoint( - async_client: AsyncClient, mock_headers: Mapping[str, str] + async_client: AsyncClient, valid_auth_headers: Mapping[str, str] ): """Test the healthcheck endpoint returns correct status and version.""" - # response = await async_client.get('/v2/healthcheck', headers=mock_headers) - response = await async_client.get("/api/healthcheck", headers=mock_headers) + # response = await async_client.get( + # '/v2/healthcheck', headers=valid_auth_headers + # ) + response = await async_client.get( + "/api/healthcheck", headers=valid_auth_headers + ) assert response.status_code == status.HTTP_200_OK assert response.json() == {"status": "healthy", "version": "2.0.0"} diff --git a/tests/test_libraries.py b/tests/test_libraries.py index 9323006..6d0fa06 100755 --- a/tests/test_libraries.py +++ b/tests/test_libraries.py @@ -1,10 +1,11 @@ import pytest import pytest_asyncio from httpx import AsyncClient -from services.database.models import Community, Library from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from app.services.database.models import Community, Library + @pytest_asyncio.fixture async def community(session: AsyncSession): diff --git a/tests/test_news.py b/tests/test_news.py index 45442ee..a47ce41 100755 --- a/tests/test_news.py +++ b/tests/test_news.py @@ -5,40 +5,53 @@ import pytest_asyncio from fastapi import status from httpx import AsyncClient -from services.database.models import Community, News from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession - -@pytest_asyncio.fixture -async def community(session: AsyncSession) -> Community: - community = Community(username="admin", email="a@a.com", password="123") - session.add(community) - await session.commit() - await session.refresh(community) - return community +from app.services.database.models import Community, News @pytest_asyncio.fixture async def news_list(community: Community) -> list[News]: news_list = [ News( - title="Python 3.12 Lançado!", + title="Test news", content="A nova versão do Python traz melhorias ...", category="release", - user_email="dev@example.com", + user_email=community.email, source_url="https://python.org/news", - tags="python, release, programming", + tags="programming", social_media_url="https://linkedin.com/pythonista", - community_id=community.id, # Usando o ID da comunidade do fixture + community_id=community.id, + ), + News( + title="Test category", + content="A nova versão do Python traz melhorias ...", + category="test_category", + user_email=community.email, + source_url="https://python.org/news", + tags="programming", + social_media_url="https://linkedin.com/pythonista", + community_id=community.id, + ), + News( + title="Test user email", + content="FastAPI agora suporta novas funcionalidades ...", + category="release", + user_email="test_user_email@test.com", + source_url="https://fastapi.com/news", + tags="programming", + social_media_url="https://twitter.com/fastapi", + likes=100, + community_id=community.id, ), News( - title="FastAPI 0.100 Lançado!", + title="Test id", content="FastAPI agora suporta novas funcionalidades ...", category="release", - user_email="example@pynews.com", + user_email=community.email, source_url="https://fastapi.com/news", - tags="fastapi, release, web", + tags="programming", social_media_url="https://twitter.com/fastapi", likes=100, ), @@ -55,19 +68,17 @@ async def test_insert_news( """ session.add(news_list[0]) await session.commit() - - statement = select(News).where(News.title == "Python 3.12 Lançado!") + statement = select(News).where(News.title == "Test news") result = await session.exec(statement) found_news = result.first() - assert found_news is not None - assert found_news.title == "Python 3.12 Lançado!" - assert found_news.content == "A nova versão do Python traz melhorias ..." - assert found_news.category == "release" - assert found_news.user_email == "dev@example.com" - assert found_news.source_url == "https://python.org/news" - assert found_news.tags == "python, release, programming" - assert found_news.social_media_url == "https://linkedin.com/pythonista" + assert found_news.title == news_list[0].title + assert found_news.content == news_list[0].content + assert found_news.category == news_list[0].category + assert found_news.user_email == news_list[0].user_email + assert found_news.source_url == news_list[0].source_url + assert found_news.tags == news_list[0].tags + assert found_news.social_media_url == news_list[0].social_media_url assert found_news.likes == 0 assert found_news.community_id == community.id assert isinstance(found_news.created_at, datetime) @@ -78,118 +89,156 @@ async def test_insert_news( @pytest.mark.asyncio async def test_post_news_endpoint( - async_client: AsyncClient, mock_headers: Mapping[str, str] + async_client: AsyncClient, valid_auth_headers: Mapping[str, str] ): """Test the news endpoint returns correct status.""" - response = await async_client.post("/api/news", headers=mock_headers) - + news_data = { + "title": "Test News", + "content": "Test news content.", + "category": "test_category", + "source_url": "https://example.com/test-news", + } + response = await async_client.post( + "/api/news", headers=valid_auth_headers, json=news_data + ) assert response.status_code == status.HTTP_200_OK assert response.json() == {"status": "News Criada"} +@pytest.mark.asyncio +async def test_insert_news_via_post_news_endpoint( + session: AsyncSession, + async_client: AsyncClient, + community: Community, + valid_auth_headers: Mapping[str, str], +): + news_data = { + "title": "Test News", + "content": "Test news content.", + "category": "test_category", + "tags": "test_tag", + "source_url": "https://example.com/test-news", + "social_media_url": "https://test.com/test_news", + } + response = await async_client.post( + "/api/news", json=news_data, headers=valid_auth_headers + ) + assert response.status_code == status.HTTP_200_OK + statement = select(News).where(News.title == news_data["title"]) + result = await session.exec(statement) + stored_news = result.first() + assert stored_news is not None + assert stored_news.title == news_data["title"] + assert stored_news.content == news_data["content"] + assert stored_news.category == news_data["category"] + assert stored_news.user_email == community.email + assert stored_news.source_url == news_data["source_url"] + assert stored_news.tags == news_data["tags"] + assert stored_news.social_media_url == news_data["social_media_url"] + assert stored_news.likes == 0 + assert isinstance(stored_news.created_at, datetime) + assert isinstance(stored_news.updated_at, datetime) + assert stored_news.created_at <= datetime.now() + assert stored_news.updated_at >= stored_news.created_at + + @pytest.mark.asyncio async def test_get_news_endpoint( session: AsyncSession, async_client: AsyncClient, - mock_headers: Mapping[str, str], + valid_auth_headers: Mapping[str, str], news_list: list, ): - session.add(news_list[0]) - session.add(news_list[1]) + session.add_all(news_list) await session.commit() - - """Test the news endpoint returns correct status and version.""" response = await async_client.get( "/api/news", - headers=mock_headers, + headers=valid_auth_headers, ) - assert response.status_code == status.HTTP_200_OK assert "news_list" in response.json() - assert len(response.json()["news_list"]) == 2 + assert len(response.json()["news_list"]) == 3 @pytest.mark.asyncio async def test_get_news_by_category( session: AsyncSession, async_client: AsyncClient, - mock_headers: Mapping[str, str], + valid_auth_headers: Mapping[str, str], news_list: list, ): - # Add news to DB session.add_all(news_list) await session.commit() - - # Filter by category response = await async_client.get( "/api/news", params={"category": "release"}, - headers={"Content-Type": "application/json"}, + headers=valid_auth_headers, ) data = response.json() assert response.status_code == status.HTTP_200_OK assert "news_list" in data assert len(data["news_list"]) == 2 titles = [news["title"] for news in data["news_list"]] - assert "Python 3.12 Lançado!" in titles - assert "FastAPI 0.100 Lançado!" in titles + assert "Test news" in titles + assert "Test id" in titles @pytest.mark.asyncio async def test_get_news_by_user_email( - session: AsyncSession, async_client: AsyncClient, news_list: list + session: AsyncSession, + async_client: AsyncClient, + news_list: list, + valid_auth_headers: Mapping[str, str], ): session.add_all(news_list) await session.commit() - response = await async_client.get( "/api/news", params={}, - headers={ - "Content-Type": "application/json", - "user-email": "dev@example.com", - }, + headers=valid_auth_headers, ) data = response.json() assert response.status_code == status.HTTP_200_OK - assert len(data["news_list"]) == 1 - assert data["news_list"][0]["user_email"] == "dev@example.com" - assert data["news_list"][0]["title"] == "Python 3.12 Lançado!" + assert len(data["news_list"]) == 3 + titles = [news["title"] for news in data["news_list"]] + assert "Test news" in titles + assert "Test category" in titles + assert "Test id" in titles @pytest.mark.asyncio async def test_get_news_by_id( session: AsyncSession, async_client: AsyncClient, - mock_headers: Mapping[str, str], + valid_auth_headers: Mapping[str, str], news_list: list, ): session.add_all(news_list) await session.commit() - # Get the id from DB - statement = select(News).where(News.title == "Python 3.12 Lançado!") + + statement = select(News).where(News.title == "Test news") result = await session.exec(statement) - news = result.first() + stored_news = result.first() + response = await async_client.get( "/api/news", - params={"id": news.id}, - headers=mock_headers, + params={"id": stored_news.id}, + headers=valid_auth_headers, ) data = response.json() assert response.status_code == status.HTTP_200_OK assert len(data["news_list"]) == 1 - assert data["news_list"][0]["id"] == news.id - assert data["news_list"][0]["title"] == "Python 3.12 Lançado!" + assert data["news_list"][0]["id"] == stored_news.id @pytest.mark.asyncio async def test_get_news_empty_result( - async_client: AsyncClient, mock_headers: Mapping[str, str] + async_client: AsyncClient, valid_auth_headers: Mapping[str, str] ): response = await async_client.get( "/api/news", params={"category": "notfound"}, - headers=mock_headers, + headers=valid_auth_headers, ) data = response.json() assert response.status_code == status.HTTP_200_OK @@ -197,4 +246,41 @@ async def test_get_news_empty_result( assert data["news_list"] == [] -# ADD like test case for News model +@pytest.mark.asyncio +async def test_news_integration( + session: AsyncSession, + async_client: AsyncClient, + community: Community, + valid_auth_headers: Mapping[str, str], +): + news_data = { + "title": "Test News", + "content": "Test news content.", + "category": "test_category", + "tags": "test_tag", + "source_url": "https://example.com/test-news", + "social_media_url": "https://test.com/test_news", + } + post_response = await async_client.post( + "/api/news", json=news_data, headers=valid_auth_headers + ) + assert post_response.status_code == status.HTTP_200_OK + get_response = await async_client.get( + "/api/news", + headers=valid_auth_headers, + ) + data = get_response.json() + assert get_response.status_code == status.HTTP_200_OK + assert "news_list" in data + assert len(data["news_list"]) == 1 + assert data["news_list"][0]["title"] == news_data["title"] + assert data["news_list"][0]["content"] == news_data["content"] + assert data["news_list"][0]["category"] == news_data["category"] + assert data["news_list"][0]["user_email"] == community.email + assert data["news_list"][0]["source_url"] == news_data["source_url"] + assert data["news_list"][0]["tags"] == news_data["tags"] + assert ( + data["news_list"][0]["social_media_url"] + == news_data["social_media_url"] + ) + assert data["news_list"][0]["likes"] == 0 diff --git a/tests/test_subscriptions.py b/tests/test_subscriptions.py index acfa785..217a3ff 100755 --- a/tests/test_subscriptions.py +++ b/tests/test_subscriptions.py @@ -1,11 +1,12 @@ import pytest import pytest_asyncio from httpx import AsyncClient -from schemas import SubscriptionTagEnum -from services.database.models import Community, Subscription from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from app.schemas import SubscriptionTagEnum +from app.services.database.models import Community, Subscription + @pytest_asyncio.fixture async def community(session: AsyncSession):