Skip to content

Commit 2fd1a8b

Browse files
authored
Merge pull request #46 from ClassConnect-org/dev
Dev
2 parents 76c68e8 + 61ac1a1 commit 2fd1a8b

7 files changed

Lines changed: 538 additions & 54 deletions

File tree

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

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from uuid import UUID
22

33
import httpx
4-
from fastapi import APIRouter, Query, Request, status
4+
from fastapi import APIRouter, Depends, Query, Request, status
55

66
from app.core.logging import setup_logger
77
from app.errors.errors import error_response
8+
from app.middleware.jw_admin_auth import jw_admin_middleware
89
from app.models.email import EmailPayload
910
from app.services.email_service import EmailService
1011

@@ -72,6 +73,20 @@
7273
}
7374
},
7475
},
76+
401: {
77+
"description": "Unauthorized - Non-admin tried to use send_to_all",
78+
"content": {
79+
"application/json": {
80+
"example": {
81+
"type": "about:blank",
82+
"title": "Unauthorized",
83+
"status": 401,
84+
"detail": "Only admins can use send_to_all.",
85+
"instance": "/notifications/email",
86+
}
87+
}
88+
},
89+
},
7590
400: {
7691
"description": "Bad Request",
7792
"content": {
@@ -89,11 +104,26 @@
89104
},
90105
)
91106
async def send_email_notification(
92-
payload: EmailPayload, request: Request, by: str = Query("id", enum=["id", "email"])
107+
payload: EmailPayload,
108+
request: Request,
109+
by: str = Query("id", enum=["id", "email"]),
110+
is_admin: bool = Depends(jw_admin_middleware),
93111
):
94112
"""
95-
Sends an email to the users if they have permissions enabled
113+
Sends an email to the users if they have permissions enabled.
96114
"""
115+
if payload.send_to_all and not is_admin:
116+
logger.warning(
117+
f"401. Unauthorized: Non-admin tried to use send_to_all. Request URL: {str(request.url.path)}"
118+
)
119+
return error_response(
120+
type_="about:blank",
121+
title="Unauthorized",
122+
status=status.HTTP_401_UNAUTHORIZED,
123+
detail="Only admins can use send_to_all.",
124+
instance=str(request.url.path),
125+
)
126+
97127
if not payload.to and not payload.send_to_all:
98128
logger.warning(
99129
f"400. Bad Request: 'to' list is empty and 'send_to_all' is False. Request URL: {str(request.url.path)}"

src/app/repository/history_repository.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ async def log_notification(
1212
recipient: UUID,
1313
event_data: dict,
1414
timestamp: Optional[datetime] = None,
15-
mongodb=mongodb,
15+
user_history_collection=None,
1616
):
17+
user_history_collection = user_history_collection or mongodb.users_collection
1718
if timestamp is None:
1819
timestamp = datetime.now(timezone.utc)
1920

@@ -30,7 +31,7 @@ async def log_notification(
3031
logger.error("Log collection not found in MongoDB.")
3132
return
3233

33-
result = await mongodb.user_history_collection.insert_one(log_entry)
34+
result = await user_history_collection.insert_one(log_entry)
3435

3536
if result.acknowledged:
3637
logger.info(f"User log entry created with ID: {result.inserted_id}")
@@ -41,11 +42,12 @@ async def log_notification(
4142
logger.error(f"Error logging event: {str(e)}")
4243

4344

44-
async def get_user_notifications(user: UUID):
45+
async def get_user_notifications(user: UUID, user_history_collection=None):
46+
user_history_collection = user_history_collection or mongodb.users_collection
4547
try:
46-
notifications_cursor = mongodb.user_history_collection.find(
47-
{"recipient": user}
48-
).sort("timestamp", -1)
48+
notifications_cursor = user_history_collection.find({"recipient": user}).sort(
49+
"timestamp", -1
50+
)
4951

5052
notifications = []
5153
async for doc in notifications_cursor:
@@ -60,9 +62,10 @@ async def get_user_notifications(user: UUID):
6062
return []
6163

6264

63-
async def set_as_read(notification_id: UUID):
65+
async def set_as_read(notification_id: UUID, user_history_collection=None):
66+
user_history_collection = user_history_collection or mongodb.users_collection
6467
try:
65-
result = await mongodb.user_history_collection.update_one(
68+
result = await user_history_collection.update_one(
6669
{"_id": str(notification_id)}, {"$set": {"read": True}}
6770
)
6871
if result.modified_count == 1:

src/app/repository/user_repository.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010

1111

1212
async def update_user_permissions(
13-
user_id: UUID, user_email: str, permissions: UserPermissions, mongodb=mongodb
13+
user_id: UUID, user_email: str, permissions: UserPermissions, users_collection=None
1414
):
15-
result = await mongodb.users_collection.update_one(
15+
users_collection = users_collection or mongodb.users_collection
16+
result = await users_collection.update_one(
1617
{"_id": user_id},
1718
{
1819
"$set": {
@@ -25,8 +26,9 @@ async def update_user_permissions(
2526
return result.modified_count
2627

2728

28-
async def get_user_permissions(user_id: UUID, mongodb=mongodb):
29-
result = await mongodb.users_collection.find_one(
29+
async def get_user_permissions(user_id: UUID, users_collection=None):
30+
users_collection = users_collection or mongodb.users_collection
31+
result = await users_collection.find_one(
3032
{"_id": user_id}, {"permissions": 1, "_id": 0}
3133
)
3234
if not result or "permissions" not in result:
@@ -36,12 +38,13 @@ async def get_user_permissions(user_id: UUID, mongodb=mongodb):
3638

3739

3840
async def get_users_with_permissions(
39-
type: NotificationType, user_ids: List[UUID], mongodb=mongodb
41+
type: NotificationType, user_ids: List[UUID], users_collection=None
4042
) -> Tuple[List[str], List[str]]:
43+
users_collection = users_collection or mongodb.users_collection
4144
projection_field = "email" if type == NotificationType.EMAIL else "_id"
4245

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

8083

81-
async def get_all_user_emails(mongodb=mongodb) -> List[str]:
82-
cursor = mongodb.users_collection.find(
84+
async def get_all_user_emails(users_collection=None) -> List[str]:
85+
users_collection = users_collection or mongodb.users_collection
86+
cursor = users_collection.find(
8387
{"email": {"$exists": True, "$ne": None}}, {"email": 1}
8488
)
8589
docs = await cursor.to_list(length=None)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from unittest.mock import AsyncMock, patch
2+
3+
import pytest
4+
import pytest_asyncio
5+
from httpx import ASGITransport, AsyncClient
6+
7+
from app.main import app
8+
from app.middleware.jw_auth import jw_auth_middleware
9+
10+
11+
@pytest_asyncio.fixture
12+
async def client():
13+
transport = ASGITransport(app=app)
14+
async with AsyncClient(
15+
transport=transport, base_url="http://notifications-test"
16+
) as ac:
17+
yield ac
18+
19+
20+
def get_permissions_url():
21+
return "/notifications/user/history"
22+
23+
24+
@pytest.mark.asyncio
25+
async def test_get_user_history_success(client):
26+
app.dependency_overrides[jw_auth_middleware] = lambda: {
27+
"user_id": "user_id_123",
28+
"user_email": "user@email.com",
29+
}
30+
with patch(
31+
"app.api.v1.endpoints.user_history.get_user_history_service"
32+
) as mock_service:
33+
mock_service.return_value = []
34+
response = await client.get(get_permissions_url())
35+
assert response.status_code == 200
36+
assert isinstance(response.json(), list)
37+
app.dependency_overrides.pop(jw_auth_middleware)
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_get_user_history_missing_auth(client):
42+
response = await client.get(get_permissions_url())
43+
assert response.status_code == 401
44+
45+
46+
@pytest.mark.asyncio
47+
async def test_read_notification_success(client):
48+
payload = {"notification_id": "123e4567-e89b-12d3-a456-426614174000"}
49+
with patch(
50+
"app.api.v1.endpoints.user_history.update_user_read_service",
51+
new_callable=AsyncMock,
52+
) as mock_update:
53+
mock_update.return_value = None
54+
55+
response = await client.put(get_permissions_url(), json=payload)
56+
assert response.status_code == 200
57+
assert response.json() == {"message": "Read succesfully set!"}
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_read_notification_http_exception(client):
62+
payload = {"notification_id": "123e4567-e89b-12d3-a456-426614174000"}
63+
64+
with patch(
65+
"app.api.v1.endpoints.user_history.update_user_read_service",
66+
new_callable=AsyncMock,
67+
) as mock_update:
68+
from fastapi import HTTPException
69+
70+
mock_update.side_effect = HTTPException(
71+
status_code=404, detail="Notification not found"
72+
)
73+
74+
response = await client.put(get_permissions_url(), json=payload)
75+
assert response.status_code == 404
76+
json_resp = response.json()
77+
assert json_resp.get("title") == "History Error"
78+
assert "Notification not found" in json_resp.get("detail", "")
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_read_notification_generic_exception(client):
83+
payload = {"notification_id": "123e4567-e89b-12d3-a456-426614174000"}
84+
85+
with patch(
86+
"app.api.v1.endpoints.user_history.update_user_read_service",
87+
new_callable=AsyncMock,
88+
) as mock_update:
89+
mock_update.side_effect = Exception("Some unexpected error")
90+
91+
response = await client.put(get_permissions_url(), json=payload)
92+
assert response.status_code == 500
93+
json_resp = response.json()
94+
assert json_resp.get("title") == "Internal Server Error"
95+
assert "Unexpected error occurred" in json_resp.get("detail", "")
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from unittest.mock import AsyncMock, MagicMock
2+
from uuid import UUID
3+
4+
import pytest
5+
6+
from app.repository.history_repository import get_user_notifications, set_as_read
7+
8+
test_user_id = UUID("5ff4f917-a32f-4859-8681-37970c1cf25f")
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_get_user_notifications_with_results():
13+
mock_docs = [
14+
{
15+
"_id": "notif1",
16+
"recipient": test_user_id,
17+
"message": "Hola",
18+
"timestamp": 123,
19+
},
20+
{
21+
"_id": "notif2",
22+
"recipient": test_user_id,
23+
"message": "Chau",
24+
"timestamp": 122,
25+
},
26+
]
27+
28+
mock_cursor = MagicMock()
29+
mock_cursor.__aiter__.return_value = iter(mock_docs)
30+
mock_cursor.sort.return_value = mock_cursor
31+
32+
mock_collection = MagicMock()
33+
mock_collection.find.return_value = mock_cursor
34+
35+
result = await get_user_notifications(
36+
test_user_id, user_history_collection=mock_collection
37+
)
38+
39+
assert result == mock_docs
40+
mock_collection.find.assert_called_once_with({"recipient": test_user_id})
41+
mock_cursor.sort.assert_called_once_with("timestamp", -1)
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_get_user_notifications_empty():
46+
mock_cursor = MagicMock()
47+
mock_cursor.__aiter__.return_value = iter([])
48+
mock_cursor.sort.return_value = mock_cursor
49+
50+
mock_collection = MagicMock()
51+
mock_collection.find.return_value = mock_cursor
52+
53+
result = await get_user_notifications(
54+
test_user_id, user_history_collection=mock_collection
55+
)
56+
57+
assert result == []
58+
mock_collection.find.assert_called_once()
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_set_as_read_success():
63+
mock_result = MagicMock()
64+
mock_result.modified_count = 1
65+
66+
mock_collection = MagicMock()
67+
mock_collection.update_one = AsyncMock(return_value=mock_result)
68+
69+
result = await set_as_read(test_user_id, user_history_collection=mock_collection)
70+
71+
assert result is True
72+
mock_collection.update_one.assert_awaited_once_with(
73+
{"_id": str(test_user_id)}, {"$set": {"read": True}}
74+
)
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_set_as_read_not_modified():
79+
mock_result = MagicMock()
80+
mock_result.modified_count = 0
81+
82+
mock_collection = MagicMock()
83+
mock_collection.update_one = AsyncMock(return_value=mock_result)
84+
85+
result = await set_as_read(test_user_id, user_history_collection=mock_collection)
86+
87+
assert result is False
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_set_as_read_exception():
92+
mock_collection = MagicMock()
93+
mock_collection.update_one = AsyncMock(side_effect=Exception("DB error"))
94+
95+
result = await set_as_read(test_user_id, user_history_collection=mock_collection)
96+
97+
assert result is False

tests/unit/test_push_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ async def test_send_push_success(push_service, payload):
4040
),
4141
patch("httpx.AsyncClient.post", new=AsyncMock(return_value=mock_response)),
4242
patch("app.services.push_service.log_event", new=AsyncMock()),
43+
patch("app.services.push_service.log_notification", new=AsyncMock()),
4344
patch.dict(
4445
"os.environ",
4546
{"ONESIGNAL_API_KEY": "fake_key", "ONESIGNAL_APP_ID": "fake_app"},
@@ -63,6 +64,7 @@ async def test_send_push_api_failure(push_service, payload):
6364
new=AsyncMock(return_value=(test_external_ids, [])),
6465
),
6566
patch("httpx.AsyncClient.post", new=AsyncMock(return_value=mock_response)),
67+
patch("app.services.push_service.log_notification", new=AsyncMock()),
6668
patch.dict(
6769
"os.environ",
6870
{"ONESIGNAL_API_KEY": "fake_key", "ONESIGNAL_APP_ID": "fake_app"},

0 commit comments

Comments
 (0)