Skip to content
Merged

Dev #46

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
36 changes: 33 additions & 3 deletions src/app/api/v1/endpoints/email.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from uuid import UUID

import httpx
from fastapi import APIRouter, Query, Request, status
from fastapi import APIRouter, Depends, Query, Request, status

from app.core.logging import setup_logger
from app.errors.errors import error_response
from app.middleware.jw_admin_auth import jw_admin_middleware
from app.models.email import EmailPayload
from app.services.email_service import EmailService

Expand Down Expand Up @@ -72,6 +73,20 @@
}
},
},
401: {
"description": "Unauthorized - Non-admin tried to use send_to_all",
"content": {
"application/json": {
"example": {
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Only admins can use send_to_all.",
"instance": "/notifications/email",
}
}
},
},
400: {
"description": "Bad Request",
"content": {
Expand All @@ -89,11 +104,26 @@
},
)
async def send_email_notification(
payload: EmailPayload, request: Request, by: str = Query("id", enum=["id", "email"])
payload: EmailPayload,
request: Request,
by: str = Query("id", enum=["id", "email"]),
is_admin: bool = Depends(jw_admin_middleware),
):
"""
Sends an email to the users if they have permissions enabled
Sends an email to the users if they have permissions enabled.
"""
if payload.send_to_all and not is_admin:
logger.warning(

Check warning on line 116 in src/app/api/v1/endpoints/email.py

View check run for this annotation

Codecov / codecov/patch

src/app/api/v1/endpoints/email.py#L116

Added line #L116 was not covered by tests
f"401. Unauthorized: Non-admin tried to use send_to_all. Request URL: {str(request.url.path)}"
)
return error_response(

Check warning on line 119 in src/app/api/v1/endpoints/email.py

View check run for this annotation

Codecov / codecov/patch

src/app/api/v1/endpoints/email.py#L119

Added line #L119 was not covered by tests
type_="about:blank",
title="Unauthorized",
status=status.HTTP_401_UNAUTHORIZED,
detail="Only admins can use send_to_all.",
instance=str(request.url.path),
)

if not payload.to and not payload.send_to_all:
logger.warning(
f"400. Bad Request: 'to' list is empty and 'send_to_all' is False. Request URL: {str(request.url.path)}"
Expand Down
19 changes: 11 additions & 8 deletions src/app/repository/history_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
recipient: UUID,
event_data: dict,
timestamp: Optional[datetime] = None,
mongodb=mongodb,
user_history_collection=None,
):
user_history_collection = user_history_collection or mongodb.users_collection

Check warning on line 17 in src/app/repository/history_repository.py

View check run for this annotation

Codecov / codecov/patch

src/app/repository/history_repository.py#L17

Added line #L17 was not covered by tests
if timestamp is None:
timestamp = datetime.now(timezone.utc)

Expand All @@ -30,7 +31,7 @@
logger.error("Log collection not found in MongoDB.")
return

result = await mongodb.user_history_collection.insert_one(log_entry)
result = await user_history_collection.insert_one(log_entry)

Check warning on line 34 in src/app/repository/history_repository.py

View check run for this annotation

Codecov / codecov/patch

src/app/repository/history_repository.py#L34

Added line #L34 was not covered by tests

if result.acknowledged:
logger.info(f"User log entry created with ID: {result.inserted_id}")
Expand All @@ -41,11 +42,12 @@
logger.error(f"Error logging event: {str(e)}")


async def get_user_notifications(user: UUID):
async def get_user_notifications(user: UUID, user_history_collection=None):
user_history_collection = user_history_collection or mongodb.users_collection
try:
notifications_cursor = mongodb.user_history_collection.find(
{"recipient": user}
).sort("timestamp", -1)
notifications_cursor = user_history_collection.find({"recipient": user}).sort(
"timestamp", -1
)

notifications = []
async for doc in notifications_cursor:
Expand All @@ -60,9 +62,10 @@
return []


async def set_as_read(notification_id: UUID):
async def set_as_read(notification_id: UUID, user_history_collection=None):
user_history_collection = user_history_collection or mongodb.users_collection
try:
result = await mongodb.user_history_collection.update_one(
result = await user_history_collection.update_one(
{"_id": str(notification_id)}, {"$set": {"read": True}}
)
if result.modified_count == 1:
Expand Down
20 changes: 12 additions & 8 deletions src/app/repository/user_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@


async def update_user_permissions(
user_id: UUID, user_email: str, permissions: UserPermissions, mongodb=mongodb
user_id: UUID, user_email: str, permissions: UserPermissions, users_collection=None
):
result = await mongodb.users_collection.update_one(
users_collection = users_collection or mongodb.users_collection
result = await users_collection.update_one(
{"_id": user_id},
{
"$set": {
Expand All @@ -25,8 +26,9 @@ async def update_user_permissions(
return result.modified_count


async def get_user_permissions(user_id: UUID, mongodb=mongodb):
result = await mongodb.users_collection.find_one(
async def get_user_permissions(user_id: UUID, users_collection=None):
users_collection = users_collection or mongodb.users_collection
result = await users_collection.find_one(
{"_id": user_id}, {"permissions": 1, "_id": 0}
)
if not result or "permissions" not in result:
Expand All @@ -36,12 +38,13 @@ async def get_user_permissions(user_id: UUID, mongodb=mongodb):


async def get_users_with_permissions(
type: NotificationType, user_ids: List[UUID], mongodb=mongodb
type: NotificationType, user_ids: List[UUID], users_collection=None
) -> Tuple[List[str], List[str]]:
users_collection = users_collection or mongodb.users_collection
projection_field = "email" if type == NotificationType.EMAIL else "_id"

# Campos que se van a traer de la DB
cursor = mongodb.users_collection.find(
cursor = users_collection.find(
{"_id": {"$in": user_ids}},
{"_id": 1, "email": 1, "permissions": 1}, # Traemos todo "permissions"
)
Expand Down Expand Up @@ -78,8 +81,9 @@ async def get_users_with_permissions(
return with_permission, without_permission


async def get_all_user_emails(mongodb=mongodb) -> List[str]:
cursor = mongodb.users_collection.find(
async def get_all_user_emails(users_collection=None) -> List[str]:
users_collection = users_collection or mongodb.users_collection
cursor = users_collection.find(
{"email": {"$exists": True, "$ne": None}}, {"email": 1}
)
docs = await cursor.to_list(length=None)
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/test_history_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from unittest.mock import AsyncMock, patch

import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient

from app.main import app
from app.middleware.jw_auth import jw_auth_middleware


@pytest_asyncio.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://notifications-test"
) as ac:
yield ac


def get_permissions_url():
return "/notifications/user/history"


@pytest.mark.asyncio
async def test_get_user_history_success(client):
app.dependency_overrides[jw_auth_middleware] = lambda: {
"user_id": "user_id_123",
"user_email": "user@email.com",
}
with patch(
"app.api.v1.endpoints.user_history.get_user_history_service"
) as mock_service:
mock_service.return_value = []
response = await client.get(get_permissions_url())
assert response.status_code == 200
assert isinstance(response.json(), list)
app.dependency_overrides.pop(jw_auth_middleware)


@pytest.mark.asyncio
async def test_get_user_history_missing_auth(client):
response = await client.get(get_permissions_url())
assert response.status_code == 401


@pytest.mark.asyncio
async def test_read_notification_success(client):
payload = {"notification_id": "123e4567-e89b-12d3-a456-426614174000"}
with patch(
"app.api.v1.endpoints.user_history.update_user_read_service",
new_callable=AsyncMock,
) as mock_update:
mock_update.return_value = None

response = await client.put(get_permissions_url(), json=payload)
assert response.status_code == 200
assert response.json() == {"message": "Read succesfully set!"}


@pytest.mark.asyncio
async def test_read_notification_http_exception(client):
payload = {"notification_id": "123e4567-e89b-12d3-a456-426614174000"}

with patch(
"app.api.v1.endpoints.user_history.update_user_read_service",
new_callable=AsyncMock,
) as mock_update:
from fastapi import HTTPException

mock_update.side_effect = HTTPException(
status_code=404, detail="Notification not found"
)

response = await client.put(get_permissions_url(), json=payload)
assert response.status_code == 404
json_resp = response.json()
assert json_resp.get("title") == "History Error"
assert "Notification not found" in json_resp.get("detail", "")


@pytest.mark.asyncio
async def test_read_notification_generic_exception(client):
payload = {"notification_id": "123e4567-e89b-12d3-a456-426614174000"}

with patch(
"app.api.v1.endpoints.user_history.update_user_read_service",
new_callable=AsyncMock,
) as mock_update:
mock_update.side_effect = Exception("Some unexpected error")

response = await client.put(get_permissions_url(), json=payload)
assert response.status_code == 500
json_resp = response.json()
assert json_resp.get("title") == "Internal Server Error"
assert "Unexpected error occurred" in json_resp.get("detail", "")
97 changes: 97 additions & 0 deletions tests/unit/test_history_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID

import pytest

from app.repository.history_repository import get_user_notifications, set_as_read

test_user_id = UUID("5ff4f917-a32f-4859-8681-37970c1cf25f")


@pytest.mark.asyncio
async def test_get_user_notifications_with_results():
mock_docs = [
{
"_id": "notif1",
"recipient": test_user_id,
"message": "Hola",
"timestamp": 123,
},
{
"_id": "notif2",
"recipient": test_user_id,
"message": "Chau",
"timestamp": 122,
},
]

mock_cursor = MagicMock()
mock_cursor.__aiter__.return_value = iter(mock_docs)
mock_cursor.sort.return_value = mock_cursor

mock_collection = MagicMock()
mock_collection.find.return_value = mock_cursor

result = await get_user_notifications(
test_user_id, user_history_collection=mock_collection
)

assert result == mock_docs
mock_collection.find.assert_called_once_with({"recipient": test_user_id})
mock_cursor.sort.assert_called_once_with("timestamp", -1)


@pytest.mark.asyncio
async def test_get_user_notifications_empty():
mock_cursor = MagicMock()
mock_cursor.__aiter__.return_value = iter([])
mock_cursor.sort.return_value = mock_cursor

mock_collection = MagicMock()
mock_collection.find.return_value = mock_cursor

result = await get_user_notifications(
test_user_id, user_history_collection=mock_collection
)

assert result == []
mock_collection.find.assert_called_once()


@pytest.mark.asyncio
async def test_set_as_read_success():
mock_result = MagicMock()
mock_result.modified_count = 1

mock_collection = MagicMock()
mock_collection.update_one = AsyncMock(return_value=mock_result)

result = await set_as_read(test_user_id, user_history_collection=mock_collection)

assert result is True
mock_collection.update_one.assert_awaited_once_with(
{"_id": str(test_user_id)}, {"$set": {"read": True}}
)


@pytest.mark.asyncio
async def test_set_as_read_not_modified():
mock_result = MagicMock()
mock_result.modified_count = 0

mock_collection = MagicMock()
mock_collection.update_one = AsyncMock(return_value=mock_result)

result = await set_as_read(test_user_id, user_history_collection=mock_collection)

assert result is False


@pytest.mark.asyncio
async def test_set_as_read_exception():
mock_collection = MagicMock()
mock_collection.update_one = AsyncMock(side_effect=Exception("DB error"))

result = await set_as_read(test_user_id, user_history_collection=mock_collection)

assert result is False
2 changes: 2 additions & 0 deletions tests/unit/test_push_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async def test_send_push_success(push_service, payload):
),
patch("httpx.AsyncClient.post", new=AsyncMock(return_value=mock_response)),
patch("app.services.push_service.log_event", new=AsyncMock()),
patch("app.services.push_service.log_notification", new=AsyncMock()),
patch.dict(
"os.environ",
{"ONESIGNAL_API_KEY": "fake_key", "ONESIGNAL_APP_ID": "fake_app"},
Expand All @@ -63,6 +64,7 @@ async def test_send_push_api_failure(push_service, payload):
new=AsyncMock(return_value=(test_external_ids, [])),
),
patch("httpx.AsyncClient.post", new=AsyncMock(return_value=mock_response)),
patch("app.services.push_service.log_notification", new=AsyncMock()),
patch.dict(
"os.environ",
{"ONESIGNAL_API_KEY": "fake_key", "ONESIGNAL_APP_ID": "fake_app"},
Expand Down
Loading