diff --git a/backend/routers/webhooks.py b/backend/routers/webhooks.py index e738ebe..19f07cc 100644 --- a/backend/routers/webhooks.py +++ b/backend/routers/webhooks.py @@ -3,6 +3,7 @@ from typing import Optional, Dict, Any from services.ingestion_service import IngestionService from services.notification_enqueue_service import NotificationEnqueueService +from services.friend_service import FriendService from services.supabase_client import get_supabase_client import logging import hmac @@ -14,6 +15,7 @@ ingestion_service = IngestionService() notification_enqueue_service = NotificationEnqueueService() +friend_service = FriendService() class EntryWebhookPayload(BaseModel): """Payload structure for entry webhook.""" @@ -22,6 +24,13 @@ class EntryWebhookPayload(BaseModel): record: Optional[Dict[str, Any]] = None old_record: Optional[Dict[str, Any]] = None +class FriendWebhookPayload(BaseModel): + """Payload structure for friend webhook.""" + type: str # 'INSERT', 'UPDATE', 'DELETE' + table: str + record: Optional[Dict[str, Any]] = None + old_record: Optional[Dict[str, Any]] = None + @router.post("/entries") async def entry_webhook( payload: EntryWebhookPayload, @@ -169,6 +178,133 @@ async def entry_webhook( detail=f"Internal server error: {str(e)}" ) + +@router.post("/friends") +async def friend_webhook( + payload: FriendWebhookPayload, + request: Request, + x_supabase_signature: Optional[str] = Header(None, alias="x-supabase-signature") +): + """ + Process friendship change webhooks for the "friendships" table. + + Handles INSERT events (new friend requests) and UPDATE events (friend request acceptance). + On INSERT with status="pending", sends a friend request notification to the recipient. + On UPDATE from "pending" to "accepted", sends an acceptance notification to the original requester. + + Parameters: + payload (FriendWebhookPayload): Webhook payload describing the change. + request (Request): The incoming HTTP request object. + x_supabase_signature (Optional[str]): Optional Supabase webhook signature header. + + Returns: + dict: Response object containing `status`, `message`, and `friendship_id`. + """ + try: + if payload.table != "friendships": + logger.warning(f"Received webhook for unexpected table: {payload.table}") + return {"status": "ignored", "message": f"Table {payload.table} not handled"} + + friendship_id = None + + if payload.type == "INSERT": + if not payload.record: + raise HTTPException(status_code=400, detail="Record missing in INSERT payload") + + friendship_id = payload.record.get("id") + status = payload.record.get("status", "pending") + logger.info(f"Processing INSERT webhook for friendship {friendship_id} with status {status}") + + # Send friend request notification if status is pending + if status == "pending": + success = await friend_service.send_friend_request_notification(payload.record) + if success: + return { + "status": "success", + "message": f"Friend request notification sent for friendship {friendship_id}", + "friendship_id": friendship_id + } + else: + logger.warning(f"Failed to send friend request notification for friendship {friendship_id}") + return { + "status": "partial_success", + "message": f"Friendship {friendship_id} processed but notification failed", + "friendship_id": friendship_id + } + else: + # Not a pending request, just acknowledge + return { + "status": "success", + "message": f"Friendship {friendship_id} inserted (status: {status})", + "friendship_id": friendship_id + } + + elif payload.type == "UPDATE": + if not payload.record: + raise HTTPException(status_code=400, detail="Record missing in UPDATE payload") + + friendship_id = payload.record.get("id") + new_status = payload.record.get("status", "") + old_status = payload.old_record.get("status", "") if payload.old_record else "" + + logger.info( + f"Processing UPDATE webhook for friendship {friendship_id}: " + f"{old_status} -> {new_status}" + ) + + # Send acceptance notification if status changed from pending to accepted + if old_status == "pending" and new_status == "accepted": + success = await friend_service.send_request_accept_notification(payload.record) + if success: + return { + "status": "success", + "message": f"Friend accept notification sent for friendship {friendship_id}", + "friendship_id": friendship_id + } + else: + logger.warning(f"Failed to send friend accept notification for friendship {friendship_id}") + return { + "status": "partial_success", + "message": f"Friendship {friendship_id} updated but notification failed", + "friendship_id": friendship_id + } + else: + # Status change that doesn't require notification + return { + "status": "success", + "message": f"Friendship {friendship_id} updated (status: {old_status} -> {new_status})", + "friendship_id": friendship_id + } + + elif payload.type == "DELETE": + if not payload.old_record: + raise HTTPException(status_code=400, detail="Old record missing in DELETE payload") + + friendship_id = payload.old_record.get("id") + logger.info(f"Processing DELETE webhook for friendship {friendship_id}") + + # No notification needed for deletion + return { + "status": "success", + "message": f"Friendship {friendship_id} deleted", + "friendship_id": friendship_id + } + + else: + raise HTTPException( + status_code=400, + detail=f"Unsupported webhook type: {payload.type}" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing friend webhook: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Internal server error: {str(e)}" + ) + @router.get("/health") async def health_check(): """Health check endpoint for webhooks.""" diff --git a/backend/services/friend_service.py b/backend/services/friend_service.py new file mode 100644 index 0000000..e779184 --- /dev/null +++ b/backend/services/friend_service.py @@ -0,0 +1,333 @@ +from typing import Dict, Any, Optional +import logging +from services.notification_service import NotificationService +from services.supabase_client import get_supabase_client +from services.cache_service import CacheService + +logger = logging.getLogger(__name__) + + +class FriendshipDict(Dict[str, Any]): + """Type definition for friendship dictionary.""" + pass + + +class ProfileDict(Dict[str, Any]): + """Type definition for profile dictionary.""" + pass + + +class FriendService: + """Service for handling friend-related notifications.""" + + def __init__(self): + """ + Initialize FriendService. + + Sets up the Supabase client, a NotificationService, and a CacheService, and logs completion. + + Attributes: + supabase: Supabase client used for database queries. + notification_service: Service responsible for enqueuing notifications. + cache_service: Cache service used for batching settings and push tokens. + """ + self.supabase = get_supabase_client() + self.notification_service = NotificationService() + self.cache_service = CacheService() + logger.info("FriendService initialized") + + async def send_friend_request_notification( + self, + friendship: FriendshipDict + ) -> bool: + """ + Send a push notification when a friend request is sent. + + When a user sends a friend request, this method notifies the recipient. + The notification is sent to the friend_id (recipient) about the user_id (sender). + + Parameters: + friendship (FriendshipDict): Dictionary representing the friendship. Expected keys: + - id (str): Friendship ID. + - user_id (str): User ID of the person sending the request. + - friend_id (str): User ID of the person receiving the request. + - status (str): Should be "pending" for a new request. + + Returns: + bool: `True` if the notification was enqueued successfully, `False` otherwise. + """ + try: + friendship_id = friendship.get("id") + sender_id = friendship.get("user_id") + recipient_id = friendship.get("friend_id") + status = friendship.get("status", "pending") + + if not friendship_id or not sender_id or not recipient_id: + logger.warning( + f"Missing required fields for friend request notification: " + f"friendship_id={friendship_id}, sender_id={sender_id}, recipient_id={recipient_id}" + ) + return False + + # Only send notification for pending requests + if status != "pending": + logger.info(f"Friendship {friendship_id} is not pending, skipping notification") + return True + + # Get sender's profile information + sender_profile = self._get_user_profile(sender_id) + if not sender_profile: + logger.warning(f"Could not find profile for friend request sender: {sender_id}") + return False + + sender_name = sender_profile.get("username") or sender_profile.get("full_name") or "Someone" + + # Check if recipient has friend_requests notifications enabled + recipient_ids = [recipient_id] + filtered_recipients = self._filter_recipients_by_notification_settings( + recipient_ids, + notification_type="friend_requests" + ) + + if not filtered_recipients: + logger.info( + f"Recipient {recipient_id} has friend_requests notifications disabled, " + f"skipping notification for friendship {friendship_id}" + ) + return True # Not an error, just user preference + + # Get push tokens for recipient + push_tokens = self._get_push_tokens_for_users(filtered_recipients) + + if not push_tokens: + logger.info(f"No push tokens found for recipient {recipient_id} of friendship {friendship_id}") + return True # Not an error, just no tokens available + + # Create notification message + title = "New Friend Request" + body = f"{sender_name} sent you a friend request" + + # Enqueue the notification + success = self.notification_service.enqueue_notification( + title=title, + body=body, + recipients=push_tokens, + priority="normal", + metadata={ + "friendship_id": friendship_id, + "sender_id": sender_id, + "recipient_id": recipient_id, + "notification_type": "friend_request" + }, + data={ + "page_url": "/friends", + } + ) + + if success: + logger.info( + f"Friend request notification enqueued: friendship_id={friendship_id}, " + f"sender={sender_name}, recipient={recipient_id}" + ) + else: + logger.error(f"Failed to enqueue friend request notification for friendship {friendship_id}") + + return success + + except Exception as e: + logger.error(f"Error enqueueing friend request notification: {str(e)}", exc_info=True) + return False + + async def send_request_accept_notification( + self, + friendship: FriendshipDict + ) -> bool: + """ + Send a push notification when a friend request is accepted. + + When a user accepts a friend request, this method notifies the original requester. + The notification is sent to the user_id (original requester) about the friend_id (accepter). + + Parameters: + friendship (FriendshipDict): Dictionary representing the friendship. Expected keys: + - id (str): Friendship ID. + - user_id (str): User ID of the person who originally sent the request. + - friend_id (str): User ID of the person who accepted the request. + - status (str): Should be "accepted". + + Returns: + bool: `True` if the notification was enqueued successfully, `False` otherwise. + """ + try: + friendship_id = friendship.get("id") + original_requester_id = friendship.get("user_id") + accepter_id = friendship.get("friend_id") + status = friendship.get("status", "") + + if not friendship_id or not original_requester_id or not accepter_id: + logger.warning( + f"Missing required fields for friend accept notification: " + f"friendship_id={friendship_id}, requester_id={original_requester_id}, accepter_id={accepter_id}" + ) + return False + + # Only send notification for accepted requests + if status != "accepted": + logger.info(f"Friendship {friendship_id} is not accepted, skipping notification") + return True + + # Get accepter's profile information + accepter_profile = self._get_user_profile(accepter_id) + if not accepter_profile: + logger.warning(f"Could not find profile for friend request accepter: {accepter_id}") + return False + + accepter_name = accepter_profile.get("username") or accepter_profile.get("full_name") or "Someone" + + # Check if original requester has friend_activity notifications enabled + recipient_ids = [original_requester_id] + filtered_recipients = self._filter_recipients_by_notification_settings( + recipient_ids, + notification_type="friend_activity" + ) + + if not filtered_recipients: + logger.info( + f"Original requester {original_requester_id} has friend_activity notifications disabled, " + f"skipping notification for friendship {friendship_id}" + ) + return True # Not an error, just user preference + + # Get push tokens for original requester + push_tokens = self._get_push_tokens_for_users(filtered_recipients) + + if not push_tokens: + logger.info( + f"No push tokens found for original requester {original_requester_id} " + f"of friendship {friendship_id}" + ) + return True # Not an error, just no tokens available + + # Create notification message + title = "Friend Request Accepted" + body = f"{accepter_name} accepted your friend request" + + # Enqueue the notification + success = self.notification_service.enqueue_notification( + title=title, + body=body, + recipients=push_tokens, + priority="normal", + metadata={ + "friendship_id": friendship_id, + "requester_id": original_requester_id, + "accepter_id": accepter_id, + "notification_type": "friend_accept" + }, + data={ + "page_url": "/friends", + } + ) + + if success: + logger.info( + f"Friend accept notification enqueued: friendship_id={friendship_id}, " + f"accepter={accepter_name}, requester={original_requester_id}" + ) + else: + logger.error(f"Failed to enqueue friend accept notification for friendship {friendship_id}") + + return success + + except Exception as e: + logger.error(f"Error enqueueing friend accept notification: {str(e)}", exc_info=True) + return False + + def _get_user_profile(self, user_id: str) -> Optional[ProfileDict]: + """ + Retrieve the profile for a given user. + + Parameters: + user_id (str): The user ID to fetch the profile for. + + Returns: + ProfileDict: Profile dictionary with keys `id`, `username`, `full_name`, and `email` if the user exists, `None` otherwise. + """ + try: + response = self.supabase.table("profiles").select( + "id, username, full_name, email" + ).eq("id", user_id).single().execute() + return response.data if response.data else None + except Exception as e: + logger.error(f"Error fetching user profile {user_id}: {str(e)}") + return None + + def _filter_recipients_by_notification_settings( + self, + user_ids: list[str], + notification_type: str = "friend_requests" + ) -> list[str]: + """ + Filter a list of user IDs to those who have a specific notification type enabled. + + Parameters: + user_ids (list[str]): User IDs to evaluate. + notification_type (str): Notification setting key to check (e.g., "friend_requests", "friend_activity"). + + Returns: + list[str]: Subset of `user_ids` with the given notification type enabled. If a user's settings are missing, the user is included by default. On error, returns the original `user_ids` (fail-open). + """ + if not user_ids: + return [] + + try: + # Get notification settings from cache (batch operation) + settings_dict = self.cache_service.get_notification_settings_batch(user_ids) + + # Filter users who have the notification type enabled + # Default to True if no settings found (opt-in by default) + enabled_user_ids = [] + for user_id in user_ids: + setting = settings_dict.get(user_id) + if setting is None: + # No settings found - default to enabled + enabled_user_ids.append(user_id) + elif setting.get(notification_type, True): + # Settings found and notification type is enabled + enabled_user_ids.append(user_id) + + return enabled_user_ids + + except Exception as e: + logger.error(f"Error filtering recipients by notification settings: {str(e)}") + # On error, return all user_ids (fail open) + return user_ids + + def _get_push_tokens_for_users(self, user_ids: list[str]) -> list[str]: + """ + Collects Expo push tokens for the given users. + + Parameters: + user_ids (list[str]): User IDs to retrieve push tokens for. + + Returns: + list[str]: Flattened list of Expo push tokens for the provided users. Returns an empty list if `user_ids` is empty or if an error occurs while fetching tokens. + """ + if not user_ids: + return [] + + try: + # Get push tokens from cache (batch operation) + tokens_dict = self.cache_service.get_push_tokens_batch(user_ids) + + # Flatten all tokens into a single list + all_tokens: list[str] = [] + for user_id in user_ids: + tokens = tokens_dict.get(user_id, []) + all_tokens.extend(tokens) + + return all_tokens + + except Exception as e: + logger.error(f"Error fetching push tokens for users: {str(e)}") + return [] diff --git a/backend/services/posthog_client.py b/backend/services/posthog_client.py new file mode 100644 index 0000000..69d6ec2 --- /dev/null +++ b/backend/services/posthog_client.py @@ -0,0 +1,12 @@ +from posthog import Posthog +from config import settings +from typing import Optional + +_posthog_client: Optional[Posthog] = None + +def get_posthog_client() -> Posthog: + """Initialize and return Posthog client instance.""" + global _posthog_client + if _posthog_client is None: + _posthog_client = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST) + return _posthog_client \ No newline at end of file diff --git a/backend/tests/test_friend_service.py b/backend/tests/test_friend_service.py new file mode 100644 index 0000000..c456f9f --- /dev/null +++ b/backend/tests/test_friend_service.py @@ -0,0 +1,414 @@ +import os +import sys +import pytest +from unittest.mock import MagicMock, patch, AsyncMock + +# Ensure the backend directory (which contains `services/`) is on sys.path +CURRENT_DIR = os.path.dirname(__file__) +BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir)) +if BACKEND_DIR not in sys.path: + sys.path.insert(0, BACKEND_DIR) + +from services.friend_service import FriendService + + +@pytest.fixture +def mock_supabase_client(): + """Create a MagicMock that simulates a Supabase client for tests.""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.data = None + + # Mock the schema().rpc().execute() chain + mock_schema = MagicMock() + mock_rpc_result = MagicMock() + mock_rpc_result.execute.return_value = mock_response + mock_schema.rpc.return_value = mock_rpc_result + mock_client.schema.return_value = mock_schema + + # Also support direct table queries + mock_table = MagicMock() + mock_table.select.return_value = mock_table + mock_table.eq.return_value = mock_table + mock_table.single.return_value = mock_table + mock_table.execute.return_value = mock_response + mock_client.table.return_value = mock_table + + return mock_client + + +@pytest.fixture +def friend_service(monkeypatch, mock_supabase_client): + """Create a FriendService instance configured for tests.""" + from services import friend_service as friend_module + monkeypatch.setattr( + friend_module, + "get_supabase_client", + lambda: mock_supabase_client + ) + + with patch("services.friend_service.CacheService") as mock_cache_class: + mock_cache = MagicMock() + mock_cache.get_notification_settings_batch.return_value = {} + mock_cache.get_push_tokens_batch.return_value = {} + mock_cache_class.return_value = mock_cache + + with patch("services.friend_service.NotificationService") as mock_notification_class: + mock_notification = MagicMock() + mock_notification.enqueue_notification.return_value = True + mock_notification_class.return_value = mock_notification + + service = FriendService() + service.cache_service = mock_cache + service.notification_service = mock_notification + return service + + +@pytest.mark.asyncio +async def test_send_friend_request_notification_success(friend_service, mock_supabase_client): + """Test successful friend request notification.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + "friend_id": "user-2", + "status": "pending" + } + + # Mock profile lookup + profile_response = MagicMock() + profile_response.data = { + "id": "user-1", + "username": "testuser", + "full_name": "Test User", + "email": "test@example.com" + } + mock_supabase_client.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = profile_response + + # Mock cache service + friend_service.cache_service.get_notification_settings_batch.return_value = { + "user-2": {"friend_requests": True} + } + friend_service.cache_service.get_push_tokens_batch.return_value = { + "user-2": ["ExponentPushToken[token-123]"] + } + + # Act + result = await friend_service.send_friend_request_notification(friendship) + + # Assert + assert result is True + friend_service.notification_service.enqueue_notification.assert_called_once() + call_args = friend_service.notification_service.enqueue_notification.call_args + assert call_args[1]["title"] == "New Friend Request" + assert "testuser" in call_args[1]["body"] or "Test User" in call_args[1]["body"] + assert call_args[1]["recipients"] == ["ExponentPushToken[token-123]"] + assert call_args[1]["metadata"]["notification_type"] == "friend_request" + assert call_args[1]["metadata"]["friendship_id"] == "friendship-123" + + +@pytest.mark.asyncio +async def test_send_friend_request_notification_missing_fields(friend_service): + """Test friend request notification with missing fields.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + # Missing friend_id + } + + # Act + result = await friend_service.send_friend_request_notification(friendship) + + # Assert + assert result is False + friend_service.notification_service.enqueue_notification.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_friend_request_notification_not_pending(friend_service): + """Test friend request notification skipped for non-pending status.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + "friend_id": "user-2", + "status": "accepted" + } + + # Act + result = await friend_service.send_friend_request_notification(friendship) + + # Assert + assert result is True # Returns True but doesn't send notification + friend_service.notification_service.enqueue_notification.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_friend_request_notification_no_profile(friend_service, mock_supabase_client): + """Test friend request notification when sender profile not found.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + "friend_id": "user-2", + "status": "pending" + } + + # Mock profile lookup returning None + profile_response = MagicMock() + profile_response.data = None + mock_supabase_client.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = profile_response + + # Act + result = await friend_service.send_friend_request_notification(friendship) + + # Assert + assert result is False + friend_service.notification_service.enqueue_notification.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_friend_request_notification_notifications_disabled(friend_service, mock_supabase_client): + """Test friend request notification when recipient has notifications disabled.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + "friend_id": "user-2", + "status": "pending" + } + + # Mock profile lookup + profile_response = MagicMock() + profile_response.data = { + "id": "user-1", + "username": "testuser", + "full_name": "Test User", + "email": "test@example.com" + } + mock_supabase_client.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = profile_response + + # Mock cache service - notifications disabled + friend_service.cache_service.get_notification_settings_batch.return_value = { + "user-2": {"friend_requests": False} + } + + # Act + result = await friend_service.send_friend_request_notification(friendship) + + # Assert + assert result is True # Returns True but doesn't send notification + friend_service.notification_service.enqueue_notification.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_friend_request_notification_no_push_tokens(friend_service, mock_supabase_client): + """Test friend request notification when recipient has no push tokens.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + "friend_id": "user-2", + "status": "pending" + } + + # Mock profile lookup + profile_response = MagicMock() + profile_response.data = { + "id": "user-1", + "username": "testuser", + "full_name": "Test User", + "email": "test@example.com" + } + mock_supabase_client.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = profile_response + + # Mock cache service - no push tokens + friend_service.cache_service.get_notification_settings_batch.return_value = { + "user-2": {"friend_requests": True} + } + friend_service.cache_service.get_push_tokens_batch.return_value = { + "user-2": [] # No tokens + } + + # Act + result = await friend_service.send_friend_request_notification(friendship) + + # Assert + assert result is True # Returns True but doesn't send notification + friend_service.notification_service.enqueue_notification.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_request_accept_notification_success(friend_service, mock_supabase_client): + """Test successful friend accept notification.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", # Original requester + "friend_id": "user-2", # Accepter + "status": "accepted" + } + + # Mock profile lookup for accepter + profile_response = MagicMock() + profile_response.data = { + "id": "user-2", + "username": "accepter", + "full_name": "Accepter User", + "email": "accepter@example.com" + } + mock_supabase_client.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = profile_response + + # Mock cache service + friend_service.cache_service.get_notification_settings_batch.return_value = { + "user-1": {"friend_activity": True} + } + friend_service.cache_service.get_push_tokens_batch.return_value = { + "user-1": ["ExponentPushToken[token-456]"] + } + + # Act + result = await friend_service.send_request_accept_notification(friendship) + + # Assert + assert result is True + friend_service.notification_service.enqueue_notification.assert_called_once() + call_args = friend_service.notification_service.enqueue_notification.call_args + assert call_args[1]["title"] == "Friend Request Accepted" + assert "accepter" in call_args[1]["body"] or "Accepter User" in call_args[1]["body"] + assert call_args[1]["recipients"] == ["ExponentPushToken[token-456]"] + assert call_args[1]["metadata"]["notification_type"] == "friend_accept" + assert call_args[1]["metadata"]["friendship_id"] == "friendship-123" + + +@pytest.mark.asyncio +async def test_send_request_accept_notification_missing_fields(friend_service): + """Test friend accept notification with missing fields.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + # Missing friend_id + } + + # Act + result = await friend_service.send_request_accept_notification(friendship) + + # Assert + assert result is False + friend_service.notification_service.enqueue_notification.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_request_accept_notification_not_accepted(friend_service): + """Test friend accept notification skipped for non-accepted status.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + "friend_id": "user-2", + "status": "pending" + } + + # Act + result = await friend_service.send_request_accept_notification(friendship) + + # Assert + assert result is True # Returns True but doesn't send notification + friend_service.notification_service.enqueue_notification.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_request_accept_notification_no_profile(friend_service, mock_supabase_client): + """Test friend accept notification when accepter profile not found.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + "friend_id": "user-2", + "status": "accepted" + } + + # Mock profile lookup returning None + profile_response = MagicMock() + profile_response.data = None + mock_supabase_client.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = profile_response + + # Act + result = await friend_service.send_request_accept_notification(friendship) + + # Assert + assert result is False + friend_service.notification_service.enqueue_notification.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_request_accept_notification_notifications_disabled(friend_service, mock_supabase_client): + """Test friend accept notification when requester has notifications disabled.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + "friend_id": "user-2", + "status": "accepted" + } + + # Mock profile lookup + profile_response = MagicMock() + profile_response.data = { + "id": "user-2", + "username": "accepter", + "full_name": "Accepter User", + "email": "accepter@example.com" + } + mock_supabase_client.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = profile_response + + # Mock cache service - notifications disabled + friend_service.cache_service.get_notification_settings_batch.return_value = { + "user-1": {"friend_activity": False} + } + + # Act + result = await friend_service.send_request_accept_notification(friendship) + + # Assert + assert result is True # Returns True but doesn't send notification + friend_service.notification_service.enqueue_notification.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_request_accept_notification_no_push_tokens(friend_service, mock_supabase_client): + """Test friend accept notification when requester has no push tokens.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "user-1", + "friend_id": "user-2", + "status": "accepted" + } + + # Mock profile lookup + profile_response = MagicMock() + profile_response.data = { + "id": "user-2", + "username": "accepter", + "full_name": "Accepter User", + "email": "accepter@example.com" + } + mock_supabase_client.table.return_value.select.return_value.eq.return_value.single.return_value.execute.return_value = profile_response + + # Mock cache service - no push tokens + friend_service.cache_service.get_notification_settings_batch.return_value = { + "user-1": {"friend_activity": True} + } + friend_service.cache_service.get_push_tokens_batch.return_value = { + "user-1": [] # No tokens + } + + # Act + result = await friend_service.send_request_accept_notification(friendship) + + # Assert + assert result is True # Returns True but doesn't send notification + friend_service.notification_service.enqueue_notification.assert_not_called() diff --git a/backend/tests/test_friend_service_integration.py b/backend/tests/test_friend_service_integration.py new file mode 100644 index 0000000..3f4fbb0 --- /dev/null +++ b/backend/tests/test_friend_service_integration.py @@ -0,0 +1,289 @@ +import os +import sys +import pytest +from unittest.mock import MagicMock, patch, AsyncMock +import json + +# Ensure the backend directory (which contains `services/`) is on sys.path +CURRENT_DIR = os.path.dirname(__file__) +BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir)) +if BACKEND_DIR not in sys.path: + sys.path.insert(0, BACKEND_DIR) + +from services.friend_service import FriendService + + +@pytest.fixture +def mock_supabase_client(): + """Create a mock Supabase client that supports schema().rpc() chain.""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.data = None + + # Mock the schema().rpc().execute() chain + mock_schema = MagicMock() + mock_rpc_result = MagicMock() + mock_rpc_result.execute.return_value = mock_response + mock_schema.rpc.return_value = mock_rpc_result + mock_client.schema.return_value = mock_schema + + # Mock table queries + mock_table = MagicMock() + mock_table.select.return_value = mock_table + mock_table.eq.return_value = mock_table + mock_table.single.return_value = mock_table + mock_table.execute.return_value = mock_response + mock_client.table.return_value = mock_table + + return mock_client + + +@pytest.fixture +def friend_service(monkeypatch, mock_supabase_client): + """Create a FriendService configured for tests with mocked dependencies.""" + from services import friend_service as friend_module + monkeypatch.setattr( + friend_module, + "get_supabase_client", + lambda: mock_supabase_client + ) + + with patch("services.friend_service.CacheService") as mock_cache_class: + mock_cache = MagicMock() + mock_cache.get_notification_settings_batch.return_value = {} + mock_cache.get_push_tokens_batch.return_value = {} + mock_cache_class.return_value = mock_cache + + with patch("services.friend_service.NotificationService") as mock_notification_class: + mock_notification = MagicMock() + mock_notification.enqueue_notification.return_value = True + mock_notification_class.return_value = mock_notification + + service = FriendService() + service.cache_service = mock_cache + service.notification_service = mock_notification + return service + + +@pytest.mark.asyncio +async def test_friend_request_notification_full_flow(friend_service, mock_supabase_client): + """Test complete flow for friend request notification.""" + # Arrange + friendship = { + "id": "friendship-123", + "user_id": "sender-1", + "friend_id": "recipient-2", + "status": "pending" + } + + # Mock sender profile + sender_profile_response = MagicMock() + sender_profile_response.data = { + "id": "sender-1", + "username": "sender_user", + "full_name": "Sender Name", + "email": "sender@example.com" + } + + # Mock table query chain + mock_table = mock_supabase_client.table.return_value + mock_table.select.return_value.eq.return_value.single.return_value.execute.return_value = sender_profile_response + + # Mock cache service + friend_service.cache_service.get_notification_settings_batch.return_value = { + "recipient-2": {"friend_requests": True} + } + friend_service.cache_service.get_push_tokens_batch.return_value = { + "recipient-2": ["ExponentPushToken[token-1]", "ExponentPushToken[token-2]"] + } + + # Act + result = await friend_service.send_friend_request_notification(friendship) + + # Assert + assert result is True + friend_service.notification_service.enqueue_notification.assert_called_once() + + # Verify notification details + call_kwargs = friend_service.notification_service.enqueue_notification.call_args[1] + assert call_kwargs["title"] == "New Friend Request" + assert "sender_user" in call_kwargs["body"] or "Sender Name" in call_kwargs["body"] + assert len(call_kwargs["recipients"]) == 2 + assert call_kwargs["metadata"]["friendship_id"] == "friendship-123" + assert call_kwargs["metadata"]["sender_id"] == "sender-1" + assert call_kwargs["metadata"]["recipient_id"] == "recipient-2" + assert call_kwargs["metadata"]["notification_type"] == "friend_request" + assert call_kwargs["data"]["page_url"] == "/friends" + + +@pytest.mark.asyncio +async def test_friend_accept_notification_full_flow(friend_service, mock_supabase_client): + """Test complete flow for friend accept notification.""" + # Arrange + friendship = { + "id": "friendship-456", + "user_id": "requester-1", # Original requester + "friend_id": "accepter-2", # Person who accepted + "status": "accepted" + } + + # Mock accepter profile + accepter_profile_response = MagicMock() + accepter_profile_response.data = { + "id": "accepter-2", + "username": "accepter_user", + "full_name": "Accepter Name", + "email": "accepter@example.com" + } + + # Mock table query chain + mock_table = mock_supabase_client.table.return_value + mock_table.select.return_value.eq.return_value.single.return_value.execute.return_value = accepter_profile_response + + # Mock cache service + friend_service.cache_service.get_notification_settings_batch.return_value = { + "requester-1": {"friend_activity": True} + } + friend_service.cache_service.get_push_tokens_batch.return_value = { + "requester-1": ["ExponentPushToken[token-3]"] + } + + # Act + result = await friend_service.send_request_accept_notification(friendship) + + # Assert + assert result is True + friend_service.notification_service.enqueue_notification.assert_called_once() + + # Verify notification details + call_kwargs = friend_service.notification_service.enqueue_notification.call_args[1] + assert call_kwargs["title"] == "Friend Request Accepted" + assert "accepter_user" in call_kwargs["body"] or "Accepter Name" in call_kwargs["body"] + assert call_kwargs["recipients"] == ["ExponentPushToken[token-3]"] + assert call_kwargs["metadata"]["friendship_id"] == "friendship-456" + assert call_kwargs["metadata"]["requester_id"] == "requester-1" + assert call_kwargs["metadata"]["accepter_id"] == "accepter-2" + assert call_kwargs["metadata"]["notification_type"] == "friend_accept" + assert call_kwargs["data"]["page_url"] == "/friends" + + +@pytest.mark.asyncio +async def test_friend_request_notification_with_fallback_name(friend_service, mock_supabase_client): + """Test friend request notification uses fallback name when username/full_name missing.""" + # Arrange + friendship = { + "id": "friendship-789", + "user_id": "sender-1", + "friend_id": "recipient-2", + "status": "pending" + } + + # Mock sender profile with no username or full_name + sender_profile_response = MagicMock() + sender_profile_response.data = { + "id": "sender-1", + "email": "sender@example.com" + } + + mock_table = mock_supabase_client.table.return_value + mock_table.select.return_value.eq.return_value.single.return_value.execute.return_value = sender_profile_response + + # Mock cache service + friend_service.cache_service.get_notification_settings_batch.return_value = { + "recipient-2": {"friend_requests": True} + } + friend_service.cache_service.get_push_tokens_batch.return_value = { + "recipient-2": ["ExponentPushToken[token-4]"] + } + + # Act + result = await friend_service.send_friend_request_notification(friendship) + + # Assert + assert result is True + call_kwargs = friend_service.notification_service.enqueue_notification.call_args[1] + assert "Someone" in call_kwargs["body"] + + +@pytest.mark.asyncio +async def test_friend_request_notification_enqueue_failure(friend_service, mock_supabase_client): + """Test friend request notification when enqueue fails.""" + # Arrange + friendship = { + "id": "friendship-999", + "user_id": "sender-1", + "friend_id": "recipient-2", + "status": "pending" + } + + # Mock sender profile + sender_profile_response = MagicMock() + sender_profile_response.data = { + "id": "sender-1", + "username": "sender_user", + "full_name": "Sender Name", + "email": "sender@example.com" + } + + mock_table = mock_supabase_client.table.return_value + mock_table.select.return_value.eq.return_value.single.return_value.execute.return_value = sender_profile_response + + # Mock cache service + friend_service.cache_service.get_notification_settings_batch.return_value = { + "recipient-2": {"friend_requests": True} + } + friend_service.cache_service.get_push_tokens_batch.return_value = { + "recipient-2": ["ExponentPushToken[token-5]"] + } + + # Mock notification service to fail + friend_service.notification_service.enqueue_notification.return_value = False + + # Act + result = await friend_service.send_friend_request_notification(friendship) + + # Assert + assert result is False + friend_service.notification_service.enqueue_notification.assert_called_once() + + +@pytest.mark.asyncio +async def test_friend_accept_notification_enqueue_failure(friend_service, mock_supabase_client): + """Test friend accept notification when enqueue fails.""" + # Arrange + friendship = { + "id": "friendship-888", + "user_id": "requester-1", + "friend_id": "accepter-2", + "status": "accepted" + } + + # Mock accepter profile + accepter_profile_response = MagicMock() + accepter_profile_response.data = { + "id": "accepter-2", + "username": "accepter_user", + "full_name": "Accepter Name", + "email": "accepter@example.com" + } + + mock_table = mock_supabase_client.table.return_value + mock_table.select.return_value.eq.return_value.single.return_value.execute.return_value = accepter_profile_response + + # Mock cache service + friend_service.cache_service.get_notification_settings_batch.return_value = { + "requester-1": {"friend_activity": True} + } + friend_service.cache_service.get_push_tokens_batch.return_value = { + "requester-1": ["ExponentPushToken[token-6]"] + } + + # Mock notification service to fail + friend_service.notification_service.enqueue_notification.return_value = False + + # Act + result = await friend_service.send_request_accept_notification(friendship) + + # Assert + assert result is False + friend_service.notification_service.enqueue_notification.assert_called_once()