diff --git a/app/routers/libraries/routes.py b/app/routers/libraries/routes.py index 259a08a..783ebd5 100644 --- a/app/routers/libraries/routes.py +++ b/app/routers/libraries/routes.py @@ -19,6 +19,7 @@ ) from app.services.database.orm.library_request import insert_library_request from app.services.database.orm.subscription import upsert_multiple_subscription +from app.services.encryption import encrypt_email from app.services.limiter import limiter @@ -145,7 +146,7 @@ async def subscribe_libraries( subscriptions = [ Subscription( - user_email=user_email, + user_email=encrypt_email(user_email), tags=body.tags, library_id=id, community_id=current_community.id, diff --git a/app/routers/news/routes.py b/app/routers/news/routes.py index ed686aa..55cf41a 100644 --- a/app/routers/news/routes.py +++ b/app/routers/news/routes.py @@ -8,7 +8,7 @@ import app.services.database.orm.news as orm_news from app.routers.authentication import get_current_active_community -from app.schemas import News +from app.schemas import News, NewsWithPublishStatus from app.services.database.models import Community as DBCommunity from app.services.limiter import limiter @@ -16,7 +16,7 @@ ALGORITHM = os.getenv("ALGORITHM", "HS256") -class NewsPostResponse(BaseModel): +class NewsCreateResponse(BaseModel): status: str = "News Criada" @@ -25,6 +25,10 @@ class NewsGetResponse(BaseModel): news_list: list = [] +class NewsUpdateResponse(BaseModel): + status: str = "News Atualizada" + + class NewsLikeResponse(BaseModel): total_likes: int | None @@ -35,11 +39,11 @@ def encode_email(email: str) -> str: def setup(): - router = APIRouter(prefix="/news", tags=["news"]) + router: APIRouter = APIRouter(prefix="/news", tags=["news"]) @router.post( - "", - response_model=NewsPostResponse, + path="", + response_model=NewsCreateResponse, status_code=status.HTTP_200_OK, summary="News endpoint", description="Creates news and returns a confirmation message", @@ -61,7 +65,7 @@ async def post_news( await orm_news.create_news( session=request.app.db_session_factory, news=news_dict ) - return NewsPostResponse() + return NewsCreateResponse() @router.get( "", @@ -93,6 +97,33 @@ async def get_news( ) return NewsGetResponse(news_list=news_list) + @router.put( + path="/{news_id}", + status_code=status.HTTP_200_OK, + summary="PUT News", + description="Updates news and sets publish value", + ) + @limiter.limit(limit_value="60/minute") + async def put_news( + request: Request, + current_community: Annotated[ + DBCommunity, Depends(get_current_active_community) + ], + news_id: str, + news: NewsWithPublishStatus, + user_email: str = Header(..., alias="user-email"), + ): + """ + Get News endpoint that retrieves news filtered by user and query params. + """ + await orm_news.update_news( + session=request.app.db_session_factory, + news=news.__dict__, + news_id=news_id, + user_email=user_email, + ) + return NewsUpdateResponse() + @router.post( path="/{news_id}/like", response_model=NewsLikeResponse, @@ -100,11 +131,11 @@ async def get_news( summary="News like endpoint", description="Allows user to like a news item", ) - @limiter.limit("60/minute") + @limiter.limit(limit_value="60/minute") async def post_like( request: Request, current_community: Annotated[ - DBCommunity, Depends(get_current_active_community) + DBCommunity, Depends(dependency=get_current_active_community) ], news_id: str, user_email: str = Header(..., alias="user-email"), @@ -127,7 +158,7 @@ async def post_like( summary="News undo like endpoint", description="Allows user to undo a like to a news item", ) - @limiter.limit("60/minute") + @limiter.limit(limit_value="60/minute") async def delete_like( request: Request, current_community: Annotated[ diff --git a/app/schemas.py b/app/schemas.py index c5a064e..3aaeef7 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -48,10 +48,13 @@ class News(BaseModel): title: str content: str category: str - tags: str | None = None source_url: str + tags: str | None = None social_media_url: str | None = None - likes: int = 0 + + +class NewsWithPublishStatus(News): + publish: bool = False class Token(BaseModel): diff --git a/app/services/database/models/news.py b/app/services/database/models/news.py index 6f1b5a5..7e79f96 100644 --- a/app/services/database/models/news.py +++ b/app/services/database/models/news.py @@ -26,6 +26,8 @@ class News(SQLModel, table=True): for this news. likes (int): Number of likes this news article has received. Defaults to 0. + publish (bool): Indicates whether the news is published or not. + Defaults to False. community_id (Optional[int]): Foreign key to the associated community (communities.id). @@ -49,6 +51,7 @@ class News(SQLModel, table=True): user_email_list: str = Field(default="[]") social_media_url: str likes: int = Field(default=0) + publish: bool = Field(default=False) # Chaves estrangeiras community_id: Optional[int] = Field( diff --git a/app/services/database/orm/news.py b/app/services/database/orm/news.py index f4e8b71..d1a7ffc 100644 --- a/app/services/database/orm/news.py +++ b/app/services/database/orm/news.py @@ -16,7 +16,6 @@ async def create_news(session: AsyncSession, news: dict) -> None: 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() @@ -42,7 +41,27 @@ async def get_news_by_query_params( statement = select(News).where(*filters) results = await session.exec(statement) - return results.all() + return list(results.all()) + + +async def update_news( + session: AsyncSession, + news: dict, + news_id: str, + user_email: str, +) -> None: + statement = select(News).where( + News.id == news_id and News.user_email == user_email + ) + results = await session.exec(statement) + news_item = results.first() + if news_item: + for key, value in news.items(): + if key != "id" and value is not None: + setattr(news_item, key, value) + session.add(news_item) + await session.commit() + await session.refresh(news_item) async def like_news( diff --git a/tests/conftest.py b/tests/conftest.py index bb76e81..93a5f92 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,7 @@ # --- Configurações do Banco de Dados em Memória para Testes --- # Usamos engine e AsyncSessionLocal apenas para os testes. # Isso garante que os testes são isolados e usam o banco de dados em memória. -TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" +TEST_DATABASE_URL = "sqlite+aiosqlite:////tmp/pynewsdb.db" os.environ["ADMIN_USER"] = "ADMIN_USER" os.environ["ADMIN_PASSWORD"] = "ADMIN_PASSWORD" os.environ["ADMIN_EMAIL"] = "ADMIN_EMAIL" diff --git a/tests/test_healthcheck.py b/tests/test_healthcheck.py index 8623d74..ee657db 100755 --- a/tests/test_healthcheck.py +++ b/tests/test_healthcheck.py @@ -18,7 +18,7 @@ async def test_healthcheck_endpoint( ) assert response.status_code == status.HTTP_200_OK - assert response.json() == {"status": "healthy", "version": "2.0.0"} + assert response.json()["version"] == "2.0.0" @pytest.mark.asyncio @@ -28,4 +28,4 @@ async def test_healthcheck_endpoint_without_auth(async_client: AsyncClient): response = await async_client.get("/api/healthcheck") assert response.status_code == status.HTTP_200_OK - assert response.json() == {"status": "healthy", "version": "2.0.0"} + assert response.json()["version"] == "2.0.0" diff --git a/tests/test_libraries_request.py b/tests/test_libraries_request.py index ad9f213..67a2f5a 100644 --- a/tests/test_libraries_request.py +++ b/tests/test_libraries_request.py @@ -6,6 +6,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.services.database.models import Community, LibraryRequest +from tests.conftest import CommunityCredentials @pytest.mark.asyncio @@ -56,5 +57,5 @@ async def test_post_libraries_endpoint( created_request = result.first() assert created_request is not None - assert created_request.user_email == community.email + assert created_request.user_email == CommunityCredentials.email assert created_request.library_home_page == "http://teste.com/" diff --git a/tests/test_news.py b/tests/test_news.py index 94a786c..ec7bf87 100755 --- a/tests/test_news.py +++ b/tests/test_news.py @@ -4,7 +4,7 @@ import pytest import pytest_asyncio from fastapi import status -from httpx import AsyncClient +from httpx import AsyncClient, Response from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -72,21 +72,22 @@ async def test_insert_news( await session.commit() 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 == 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) - assert isinstance(found_news.updated_at, datetime) - assert found_news.created_at <= datetime.now() - assert found_news.updated_at >= found_news.created_at + stored_news = result.first() + assert stored_news is not None + assert stored_news.title == news_list[0].title + assert stored_news.content == news_list[0].content + assert stored_news.category == news_list[0].category + assert stored_news.user_email == news_list[0].user_email + assert stored_news.source_url == news_list[0].source_url + assert stored_news.tags == news_list[0].tags + assert stored_news.social_media_url == news_list[0].social_media_url + assert stored_news.likes == 0 + assert stored_news.community_id == community.id + 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 + assert stored_news.publish is False @pytest.mark.asyncio @@ -100,8 +101,8 @@ async def test_post_news_endpoint( "category": "test_category", "source_url": "https://example.com/test-news", } - response = await async_client.post( - "/api/news", headers=valid_auth_headers, json=news_data + response: Response = await async_client.post( + url="/api/news", headers=valid_auth_headers, json=news_data ) assert response.status_code == status.HTTP_200_OK assert response.json() == {"status": "News Criada"} @@ -221,6 +222,7 @@ async def test_get_news_by_id( statement = select(News).where(News.title == "Test news") result = await session.exec(statement) stored_news = result.first() + assert stored_news is not None response = await async_client.get( "/api/news", @@ -288,6 +290,59 @@ async def test_news_integration( assert data["news_list"][0]["likes"] == 0 +@pytest.mark.asyncio +async def test_put_news_endpoint( + session: AsyncSession, + async_client: AsyncClient, + valid_auth_headers: Mapping[str, str], + news_list: list, +): + session.add_all(news_list) + await session.commit() + + statement = select(News).where(News.title == "Test news") + result = await session.exec(statement) + stored_news = result.first() + assert stored_news is not None + assert stored_news.publish is False + + data: dict = { + "title": "updated title", + "content": "updated content", + "category": "updated_category", + "source_url": "https://updated_url.com", + "tags": "test_tag_updated", + "social_media_url": "https://updated_social_media_url.com", + } + + response: Response = await async_client.put( + url=f"/api/news/{stored_news.id}", + headers=valid_auth_headers, + json={ + "title": data["title"], + "content": data["content"], + "category": data["category"], + "source_url": data["source_url"], + "tags": data["tags"], + "social_media_url": data["social_media_url"], + "publish": True, + }, + ) + assert response.status_code == status.HTTP_200_OK + + statement = select(News).where(News.title == data["title"]) + result = await session.exec(statement) + stored_news = result.first() + assert stored_news is not None + assert stored_news.content == data["content"] + assert stored_news.category == data["category"] + assert stored_news.user_email == valid_auth_headers["user-email"] + assert stored_news.source_url == data["source_url"] + assert stored_news.tags == data["tags"] + assert stored_news.social_media_url == data["social_media_url"] + assert stored_news.publish + + @pytest.mark.asyncio async def test_news_likes_endpoint( session: AsyncSession, @@ -303,8 +358,8 @@ async def test_news_likes_endpoint( "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 + response: Response = await async_client.post( + url="/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"]) @@ -316,8 +371,8 @@ async def test_news_likes_endpoint( emails = ["like@test.com", "like2@test.com"] # Add likes - response = await async_client.post( - f"/api/news/{stored_news.id}/like", + response: Response = await async_client.post( + url=f"/api/news/{stored_news.id}/like", json={"email": emails[0]}, headers={**valid_auth_headers, "user-email": emails[0]}, ) @@ -325,17 +380,19 @@ async def test_news_likes_endpoint( 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.likes == 1 assert stored_news.user_email_list == f"['{encode_email(emails[0])}']" - response = await async_client.post( - f"/api/news/{stored_news.id}/like", + response: Response = await async_client.post( + url=f"/api/news/{stored_news.id}/like", headers={**valid_auth_headers, "user-email": emails[1]}, ) 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.likes == 2 assert ( stored_news.user_email_list @@ -343,24 +400,58 @@ async def test_news_likes_endpoint( ) # Remove likes - response = await async_client.delete( - f"/api/news/{stored_news.id}/like", + response: Response = await async_client.delete( + url=f"/api/news/{stored_news.id}/like", headers={**valid_auth_headers, "user-email": emails[0]}, ) 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.likes == 1 assert stored_news.user_email_list == f"['{encode_email(emails[1])}']" - response = await async_client.delete( - f"/api/news/{stored_news.id}/like", + response: Response = await async_client.delete( + url=f"/api/news/{stored_news.id}/like", headers={**valid_auth_headers, "user-email": emails[1]}, ) 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.likes == 0 assert stored_news.user_email_list == "[]" + + +@pytest.mark.asyncio +async def test_news_endpoint_blocks_unauthorized_access( + async_client: AsyncClient, +): + 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: Response = await async_client.post( + url="/api/news", json=news_data + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + response: Response = await async_client.get(url="/api/news") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + response: Response = await async_client.put( + url="/api/news/1", json=news_data + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + response: Response = await async_client.post(url="/api/news/1/like") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + response: Response = await async_client.delete(url="/api/news/1/like") + assert response.status_code == status.HTTP_401_UNAUTHORIZED \ No newline at end of file diff --git a/tests/test_subscriptions.py b/tests/test_subscriptions.py index 5f2078d..eae16fa 100755 --- a/tests/test_subscriptions.py +++ b/tests/test_subscriptions.py @@ -2,6 +2,7 @@ import pytest from httpx import AsyncClient +from services.encryption import encrypt_email from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -111,9 +112,12 @@ async def test_post_subscribe_endpoint( assert response.status_code == 200 assert response.json()["status"] == "Subscribed in libraries successfully" + encripted_email = encrypt_email(valid_auth_headers["user-email"]) + statement = select(Subscription).where( - Subscription.user_email == community.email + Subscription.user_email == encripted_email ) + result = await session.exec(statement) created_subscriptions = result.all()