From 17551d662bf449ef1afed1ad96e9c1e3d16cc46a Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:01:57 +0800 Subject: [PATCH] feat(friendships): implement FriendshipService with TDD - Add send_request(), accept_request(), decline_request(), cancel_request() - Add get_inbox(), get_outgoing_requests(), get_friends_list(), remove_friend() - Add unit tests for all service methods (14 tests) - Update FriendRequestResponse schema to include 'canceled' status - Follow async SQLAlchemy patterns and BaseService conventions --- backend/src/v1/friendships/repository.py | 220 +++++++ backend/src/v1/friendships/schemas.py | 2 +- backend/src/v1/friendships/service.py | 438 +++++++++++++ .../friendships/test_friendship_repository.py | 380 ++++++++++++ .../v1/friendships/test_friendship_service.py | 582 ++++++++++++++++++ 5 files changed, 1621 insertions(+), 1 deletion(-) create mode 100644 backend/tests/unit/v1/friendships/test_friendship_repository.py create mode 100644 backend/tests/unit/v1/friendships/test_friendship_service.py diff --git a/backend/src/v1/friendships/repository.py b/backend/src/v1/friendships/repository.py index 9d48db4..9b16e5a 100644 --- a/backend/src/v1/friendships/repository.py +++ b/backend/src/v1/friendships/repository.py @@ -1 +1,221 @@ from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol +from uuid import UUID + +from sqlalchemy import select, or_ +from sqlalchemy.exc import SQLAlchemyError + +from core.db.base_repository import BaseRepository +from core.logging import get_logger +from models.friendships import Friendship, FriendshipStatus +from models.inbox_messages import InboxMessage, InboxMessageStatus, InboxMessageType + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.friendships.repository") + + +class FriendshipRepository(Protocol): + """Protocol defining the friendship repository interface.""" + + async def create_request( + self, initiator_id: UUID, recipient_id: UUID + ) -> tuple[Friendship, InboxMessage]: + """Create a friendship request and inbox message.""" + ... + + async def get_friendship_between_users( + self, user_id_1: UUID, user_id_2: UUID + ) -> Friendship | None: + """Check if friendship exists between two users.""" + ... + + async def get_pending_inbox_for_recipient( + self, recipient_id: UUID, friendship_id: UUID + ) -> InboxMessage | None: + """Get pending inbox message for recipient.""" + ... + + async def get_friendship_by_id(self, friendship_id: UUID) -> Friendship | None: + """Get friendship by ID.""" + ... + + async def get_inbox_messages_for_user( + self, user_id: UUID, status: InboxMessageStatus | None = None + ) -> list[InboxMessage]: + """Get inbox messages for a user.""" + ... + + async def get_outgoing_requests(self, user_id: UUID) -> list[Friendship]: + """Get outgoing friend requests.""" + ... + + async def get_friends_list(self, user_id: UUID) -> list[Friendship]: + """Get accepted friends list.""" + ... + + +class SQLAlchemyFriendshipRepository(BaseRepository[Friendship]): + """SQLAlchemy implementation of FriendshipRepository. + + Note: This repository only performs CRUD operations. + - No commit (only flush) - service layer handles transactions + - No auth logic - service layer handles authorization + - No HTTP exceptions - returns None or raises SQLAlchemyError + """ + + def __init__(self, session: AsyncSession) -> None: + super().__init__(session, Friendship) + + async def create_request( + self, initiator_id: UUID, recipient_id: UUID + ) -> tuple[Friendship, InboxMessage]: + try: + user_low_id = min(initiator_id, recipient_id) + user_high_id = max(initiator_id, recipient_id) + + friendship = Friendship( + user_low_id=user_low_id, + user_high_id=user_high_id, + initiator_id=initiator_id, + status=FriendshipStatus.PENDING, + requested_at=UUID(int=0), + ) + self._session.add(friendship) + await self._session.flush() + + inbox = InboxMessage( + recipient_id=recipient_id, + sender_id=initiator_id, + message_type=InboxMessageType.FRIEND_REQUEST, + friendship_id=friendship.id, + status=InboxMessageStatus.PENDING, + ) + self._session.add(inbox) + await self._session.flush() + + return friendship, inbox + except SQLAlchemyError: + logger.exception( + "Failed to create friendship request", + initiator_id=str(initiator_id), + recipient_id=str(recipient_id), + ) + raise + + async def get_friendship_between_users( + self, user_id_1: UUID, user_id_2: UUID + ) -> Friendship | None: + try: + user_low_id = min(user_id_1, user_id_2) + user_high_id = max(user_id_1, user_id_2) + + stmt = ( + select(Friendship) + .where(Friendship.user_low_id == user_low_id) + .where(Friendship.user_high_id == user_high_id) + .where(Friendship.deleted_at.is_(None)) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + except SQLAlchemyError: + logger.exception( + "Failed to get friendship between users", + user_id_1=str(user_id_1), + user_id_2=str(user_id_2), + ) + raise + + async def get_pending_inbox_for_recipient( + self, recipient_id: UUID, friendship_id: UUID + ) -> InboxMessage | None: + try: + stmt = ( + select(InboxMessage) + .where(InboxMessage.recipient_id == recipient_id) + .where(InboxMessage.friendship_id == friendship_id) + .where(InboxMessage.status == InboxMessageStatus.PENDING) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + except SQLAlchemyError: + logger.exception( + "Failed to get pending inbox message", + recipient_id=str(recipient_id), + friendship_id=str(friendship_id), + ) + raise + + async def get_friendship_by_id(self, friendship_id: UUID) -> Friendship | None: + try: + return await self.get_by_id(friendship_id) + except SQLAlchemyError: + logger.exception( + "Failed to get friendship by id", + friendship_id=str(friendship_id), + ) + raise + + async def get_inbox_messages_for_user( + self, user_id: UUID, status: InboxMessageStatus | None = None + ) -> list[InboxMessage]: + try: + stmt = ( + select(InboxMessage) + .where(InboxMessage.recipient_id == user_id) + .order_by(InboxMessage.created_at.desc()) + ) + if status is not None: + stmt = stmt.where(InboxMessage.status == status) + + result = await self._session.execute(stmt) + return list(result.scalars().all()) + except SQLAlchemyError: + logger.exception( + "Failed to get inbox messages for user", + user_id=str(user_id), + ) + raise + + async def get_outgoing_requests(self, user_id: UUID) -> list[Friendship]: + try: + stmt = ( + select(Friendship) + .where(Friendship.initiator_id == user_id) + .where(Friendship.status == FriendshipStatus.PENDING) + .where(Friendship.deleted_at.is_(None)) + .order_by(Friendship.created_at.desc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + except SQLAlchemyError: + logger.exception( + "Failed to get outgoing requests", + user_id=str(user_id), + ) + raise + + async def get_friends_list(self, user_id: UUID) -> list[Friendship]: + try: + stmt = ( + select(Friendship) + .where( + or_( + Friendship.user_low_id == user_id, + Friendship.user_high_id == user_id, + ) + ) + .where(Friendship.status == FriendshipStatus.ACCEPTED) + .where(Friendship.deleted_at.is_(None)) + .order_by(Friendship.updated_at.desc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + except SQLAlchemyError: + logger.exception( + "Failed to get friends list", + user_id=str(user_id), + ) + raise diff --git a/backend/src/v1/friendships/schemas.py b/backend/src/v1/friendships/schemas.py index b9b8ee2..214ad45 100644 --- a/backend/src/v1/friendships/schemas.py +++ b/backend/src/v1/friendships/schemas.py @@ -24,7 +24,7 @@ class FriendRequestResponse(BaseModel): sender: UserBasicInfo recipient: UserBasicInfo content: str | None - status: Literal["pending", "accepted", "rejected"] + status: Literal["pending", "accepted", "rejected", "canceled"] created_at: datetime diff --git a/backend/src/v1/friendships/service.py b/backend/src/v1/friendships/service.py index 9d48db4..36c9a07 100644 --- a/backend/src/v1/friendships/service.py +++ b/backend/src/v1/friendships/service.py @@ -1 +1,439 @@ from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError + +from core.auth.models import CurrentUser +from core.db.base_service import BaseService +from core.logging import get_logger +from models.friendships import FriendshipStatus +from models.inbox_messages import InboxMessageStatus, InboxMessageType +from v1.friendships.repository import FriendshipRepository +from v1.friendships.schemas import ( + FriendRequestCreate, + FriendRequestResponse, + FriendResponse, +) +from v1.users.repository import UserRepository + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + from v1.friendships.schemas import UserBasicInfo + + +logger = get_logger("v1.friendships.service") + + +class FriendshipService(BaseService): + """Friendship service handling friend requests and friends list. + + Responsibilities: + - Authorization checks + - Validation (not self, not already friends, not blocked) + - Transaction boundary (commit/rollback) + - Converting ORM models to response schemas + """ + + _repository: FriendshipRepository + _user_repository: UserRepository + _session: AsyncSession + + def __init__( + self, + repository: FriendshipRepository, + user_repository: UserRepository, + session: AsyncSession, + current_user: CurrentUser | None, + ) -> None: + super().__init__(current_user=current_user) + self._repository = repository + self._user_repository = user_repository + self._session = session + + async def send_request(self, request: FriendRequestCreate) -> FriendRequestResponse: + user_id = self.require_user_id() + target_user_id = request.target_user_id + + if user_id == target_user_id: + raise HTTPException( + status_code=400, detail="Cannot send friend request to yourself" + ) + + existing = await self._repository.get_friendship_between_users( + user_id, target_user_id + ) + if existing: + if existing.status == FriendshipStatus.ACCEPTED: + raise HTTPException( + status_code=400, detail="Already friends with this user" + ) + if existing.status == FriendshipStatus.BLOCKED: + raise HTTPException( + status_code=400, detail="Cannot send friend request to blocked user" + ) + + try: + friendship, inbox = await self._repository.create_request( + user_id, target_user_id + ) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + sender = await self._user_repository.get_by_user_id(user_id) + recipient = await self._user_repository.get_by_user_id(target_user_id) + + return FriendRequestResponse( + id=friendship.id, + sender=self._build_user_basic_info(sender), + recipient=self._build_user_basic_info(recipient), + content=inbox.content, + status="pending", + created_at=friendship.created_at, + ) + + async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse: + user_id = self.require_user_id() + + try: + friendship = await self._repository.get_friendship_by_id(friendship_id) + except SQLAlchemyError: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + if friendship is None: + raise HTTPException(status_code=404, detail="Friend request not found") + + recipient_id = ( + friendship.user_low_id + if friendship.initiator_id == friendship.user_high_id + else friendship.user_high_id + ) + + if recipient_id != user_id: + raise HTTPException( + status_code=403, detail="Not authorized to accept this request" + ) + + if friendship.status != FriendshipStatus.PENDING: + raise HTTPException(status_code=400, detail="Friend request is not pending") + + inbox = await self._repository.get_pending_inbox_for_recipient( + user_id, friendship_id + ) + if inbox is None: + raise HTTPException(status_code=404, detail="Inbox message not found") + + friendship.status = FriendshipStatus.ACCEPTED + inbox.status = InboxMessageStatus.ACCEPTED + + try: + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + sender_id = friendship.initiator_id + if sender_id is None: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + sender = await self._user_repository.get_by_user_id(sender_id) + recipient = await self._user_repository.get_by_user_id(user_id) + + return FriendRequestResponse( + id=friendship.id, + sender=self._build_user_basic_info(sender), + recipient=self._build_user_basic_info(recipient), + content=inbox.content, + status="accepted", + created_at=friendship.created_at, + ) + + async def decline_request(self, friendship_id: UUID) -> FriendRequestResponse: + user_id = self.require_user_id() + + try: + friendship = await self._repository.get_friendship_by_id(friendship_id) + except SQLAlchemyError: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + if friendship is None: + raise HTTPException(status_code=404, detail="Friend request not found") + + recipient_id = ( + friendship.user_low_id + if friendship.initiator_id == friendship.user_high_id + else friendship.user_high_id + ) + + if recipient_id != user_id: + raise HTTPException( + status_code=403, detail="Not authorized to decline this request" + ) + + if friendship.status != FriendshipStatus.PENDING: + raise HTTPException(status_code=400, detail="Friend request is not pending") + + inbox = await self._repository.get_pending_inbox_for_recipient( + user_id, friendship_id + ) + + friendship.status = FriendshipStatus.DECLINED + if inbox: + inbox.status = InboxMessageStatus.REJECTED + + try: + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + sender_id = friendship.initiator_id + if sender_id is None: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + sender = await self._user_repository.get_by_user_id(sender_id) + recipient = await self._user_repository.get_by_user_id(user_id) + + return FriendRequestResponse( + id=friendship.id, + sender=self._build_user_basic_info(sender), + recipient=self._build_user_basic_info(recipient), + content=inbox.content if inbox else None, + status="rejected", + created_at=friendship.created_at, + ) + + async def cancel_request(self, friendship_id: UUID) -> FriendRequestResponse: + user_id = self.require_user_id() + + try: + friendship = await self._repository.get_friendship_by_id(friendship_id) + except SQLAlchemyError: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + if friendship is None: + raise HTTPException(status_code=404, detail="Friend request not found") + + if friendship.initiator_id != user_id: + raise HTTPException( + status_code=403, detail="Not authorized to cancel this request" + ) + + if friendship.status != FriendshipStatus.PENDING: + raise HTTPException(status_code=400, detail="Friend request is not pending") + + inbox = await self._repository.get_pending_inbox_for_recipient( + friendship.user_high_id, friendship_id + ) + + friendship.status = FriendshipStatus.CANCELED + if inbox: + inbox.status = InboxMessageStatus.DISMISSED + + try: + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + sender = await self._user_repository.get_by_user_id(user_id) + recipient_id = friendship.user_high_id + if recipient_id is None: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + recipient = await self._user_repository.get_by_user_id(recipient_id) + + return FriendRequestResponse( + id=friendship.id, + sender=self._build_user_basic_info(sender), + recipient=self._build_user_basic_info(recipient), + content=inbox.content if inbox else None, + status="canceled", + created_at=friendship.created_at, + ) + + async def get_inbox(self) -> list[FriendRequestResponse]: + user_id = self.require_user_id() + + try: + inbox_messages = await self._repository.get_inbox_messages_for_user( + user_id, InboxMessageStatus.PENDING + ) + except SQLAlchemyError: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + result: list[FriendRequestResponse] = [] + for inbox in inbox_messages: + if inbox.message_type != InboxMessageType.FRIEND_REQUEST: + continue + + friendship_id = inbox.friendship_id + if friendship_id is None: + continue + + friendship = await self._repository.get_friendship_by_id(friendship_id) + if friendship is None or friendship.status != FriendshipStatus.PENDING: + continue + + sender_id = inbox.sender_id + if sender_id is None: + continue + + sender = await self._user_repository.get_by_user_id(sender_id) + recipient = await self._user_repository.get_by_user_id(user_id) + + result.append( + FriendRequestResponse( + id=friendship.id, + sender=self._build_user_basic_info(sender), + recipient=self._build_user_basic_info(recipient), + content=inbox.content, + status="pending", + created_at=friendship.created_at, + ) + ) + + return result + + async def get_outgoing_requests(self) -> list[FriendRequestResponse]: + user_id = self.require_user_id() + + try: + outgoing = await self._repository.get_outgoing_requests(user_id) + except SQLAlchemyError: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + result: list[FriendRequestResponse] = [] + for friendship in outgoing: + recipient_id = ( + friendship.user_low_id + if friendship.initiator_id == friendship.user_high_id + else friendship.user_high_id + ) + sender = await self._user_repository.get_by_user_id(user_id) + recipient = await self._user_repository.get_by_user_id(recipient_id) + + result.append( + FriendRequestResponse( + id=friendship.id, + sender=self._build_user_basic_info(sender), + recipient=self._build_user_basic_info(recipient), + content=None, + status="pending", + created_at=friendship.created_at, + ) + ) + + return result + + async def get_friends_list(self) -> list[FriendResponse]: + user_id = self.require_user_id() + + try: + friendships = await self._repository.get_friends_list(user_id) + except SQLAlchemyError: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + result: list[FriendResponse] = [] + for friendship in friendships: + friend_id = ( + friendship.user_high_id + if friendship.user_low_id == user_id + else friendship.user_low_id + ) + friend = await self._user_repository.get_by_user_id(friend_id) + + result.append( + FriendResponse( + id=friendship.id, + friend=self._build_user_basic_info(friend), + status="active", + created_at=friendship.created_at, + accepted_at=friendship.updated_at, + ) + ) + + return result + + async def remove_friend(self, friend_id: UUID) -> FriendResponse: + user_id = self.require_user_id() + + try: + friendship = await self._repository.get_friendship_between_users( + user_id, friend_id + ) + except SQLAlchemyError: + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + if friendship is None: + raise HTTPException(status_code=404, detail="Friendship not found") + + if friendship.status != FriendshipStatus.ACCEPTED: + raise HTTPException( + status_code=400, detail="Can only remove accepted friendships" + ) + + friendship.deleted_at = datetime.now(timezone.utc) + + try: + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException( + status_code=503, detail="Friendship service unavailable" + ) + + friend = await self._user_repository.get_by_user_id(friend_id) + + return FriendResponse( + id=friendship.id, + friend=self._build_user_basic_info(friend), + status="active", + created_at=friendship.created_at, + accepted_at=friendship.updated_at, + ) + + def _build_user_basic_info(self, profile: Any) -> "UserBasicInfo": + from v1.friendships.schemas import UserBasicInfo + + if profile is None: + return UserBasicInfo(id="", username="") + + p = profile # type: ignore[assignment] + return UserBasicInfo( + id=str(p.id), + username=p.username, + avatar_url=p.avatar_url if hasattr(p, "avatar_url") else None, + ) diff --git a/backend/tests/unit/v1/friendships/test_friendship_repository.py b/backend/tests/unit/v1/friendships/test_friendship_repository.py new file mode 100644 index 0000000..24aedd3 --- /dev/null +++ b/backend/tests/unit/v1/friendships/test_friendship_repository.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import uuid +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from models.friendships import Friendship, FriendshipStatus +from models.inbox_messages import InboxMessage, InboxMessageStatus, InboxMessageType + + +class FakeFriendshipRepository: + """Fake implementation for testing.""" + + def __init__(self) -> None: + self.friendships: dict[uuid.UUID, Friendship] = {} + self.inbox_messages: dict[uuid.UUID, InboxMessage] = {} + + async def create_request( + self, + initiator_id: uuid.UUID, + recipient_id: uuid.UUID, + ) -> tuple[Friendship, InboxMessage]: + raise NotImplementedError + + async def get_friendship_between_users( + self, user_id_1: uuid.UUID, user_id_2: uuid.UUID + ) -> Friendship | None: + raise NotImplementedError + + async def get_pending_inbox_for_recipient( + self, recipient_id: uuid.UUID, friendship_id: uuid.UUID + ) -> InboxMessage | None: + raise NotImplementedError + + async def get_friendship_by_id(self, friendship_id: uuid.UUID) -> Friendship | None: + raise NotImplementedError + + async def get_inbox_messages_for_user( + self, user_id: uuid.UUID, status: InboxMessageStatus | None = None + ) -> list[InboxMessage]: + raise NotImplementedError + + async def get_outgoing_requests(self, user_id: uuid.UUID) -> list[Friendship]: + raise NotImplementedError + + async def get_friends_list(self, user_id: uuid.UUID) -> list[Friendship]: + raise NotImplementedError + + +class TestFriendshipRepository: + """Tests for FriendshipRepository.""" + + @pytest.fixture + def mock_session(self) -> MagicMock: + session = MagicMock() + session.execute = AsyncMock() + session.flush = AsyncMock() + session.add = MagicMock() + session.commit = AsyncMock() + return session + + @pytest.fixture + def repository(self, mock_session: MagicMock) -> Any: + from v1.friendships.repository import SQLAlchemyFriendshipRepository + + return SQLAlchemyFriendshipRepository(mock_session) + + @pytest.mark.asyncio + async def test_create_request_creates_friendship_and_inbox( + self, repository: Any, mock_session: MagicMock + ) -> None: + initiator_id = uuid.uuid4() + recipient_id = uuid.uuid4() + + result_friendship = Friendship( + id=uuid.uuid4(), + user_low_id=min(initiator_id, recipient_id), + user_high_id=max(initiator_id, recipient_id), + initiator_id=initiator_id, + status=FriendshipStatus.PENDING, + requested_at=uuid.uuid4(), + ) + result_inbox = InboxMessage( + id=uuid.uuid4(), + recipient_id=recipient_id, + sender_id=initiator_id, + message_type=InboxMessageType.FRIEND_REQUEST, + status=InboxMessageStatus.PENDING, + ) + + mock_execute = AsyncMock( + return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)) + ) + mock_session.execute = mock_execute + mock_session.add = MagicMock() + + class MockExecuteResult: + def __init__(self, returning): + self._returning = returning + + def scalar_one_or_none(self): + return self._returning + + async def mock_execute_func(stmt): + if "INSERT INTO friendships" in str(stmt): + return MockExecuteResult(result_friendship) + elif "INSERT INTO inbox_messages" in str(stmt): + return MockExecuteResult(result_inbox) + return MockExecuteResult(None) + + mock_session.execute = AsyncMock(side_effect=mock_execute_func) + + friendship, inbox = await repository.create_request(initiator_id, recipient_id) + + assert friendship is not None + assert inbox is not None + assert friendship.initiator_id == initiator_id + assert inbox.recipient_id == recipient_id + + @pytest.mark.asyncio + async def test_get_friendship_between_users_returns_friendship( + self, repository: Any, mock_session: MagicMock + ) -> None: + user_id_1 = uuid.uuid4() + user_id_2 = uuid.uuid4() + expected_friendship = Friendship( + id=uuid.uuid4(), + user_low_id=min(user_id_1, user_id_2), + user_high_id=max(user_id_1, user_id_2), + initiator_id=user_id_1, + status=FriendshipStatus.ACCEPTED, + ) + + class MockExecuteResult: + def scalar_one_or_none(self): + return expected_friendship + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_friendship_between_users(user_id_1, user_id_2) + + assert result is not None + assert result.id == expected_friendship.id + + @pytest.mark.asyncio + async def test_get_friendship_between_users_returns_none( + self, repository: Any, mock_session: MagicMock + ) -> None: + user_id_1 = uuid.uuid4() + user_id_2 = uuid.uuid4() + + class MockExecuteResult: + def scalar_one_or_none(self): + return None + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_friendship_between_users(user_id_1, user_id_2) + + assert result is None + + @pytest.mark.asyncio + async def test_get_pending_inbox_for_recipient_returns_inbox( + self, repository: Any, mock_session: MagicMock + ) -> None: + recipient_id = uuid.uuid4() + friendship_id = uuid.uuid4() + expected_inbox = InboxMessage( + id=uuid.uuid4(), + recipient_id=recipient_id, + sender_id=uuid.uuid4(), + message_type=InboxMessageType.FRIEND_REQUEST, + friendship_id=friendship_id, + status=InboxMessageStatus.PENDING, + ) + + class MockExecuteResult: + def scalar_one_or_none(self): + return expected_inbox + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_pending_inbox_for_recipient( + recipient_id, friendship_id + ) + + assert result is not None + assert result.id == expected_inbox.id + + @pytest.mark.asyncio + async def test_get_pending_inbox_for_recipient_returns_none( + self, repository: Any, mock_session: MagicMock + ) -> None: + recipient_id = uuid.uuid4() + friendship_id = uuid.uuid4() + + class MockExecuteResult: + def scalar_one_or_none(self): + return None + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_pending_inbox_for_recipient( + recipient_id, friendship_id + ) + + assert result is None + + @pytest.mark.asyncio + async def test_get_friendship_by_id_returns_friendship( + self, repository: Any, mock_session: MagicMock + ) -> None: + friendship_id = uuid.uuid4() + expected_friendship = Friendship( + id=friendship_id, + user_low_id=uuid.uuid4(), + user_high_id=uuid.uuid4(), + initiator_id=uuid.uuid4(), + status=FriendshipStatus.ACCEPTED, + ) + + class MockExecuteResult: + def scalar_one_or_none(self): + return expected_friendship + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_friendship_by_id(friendship_id) + + assert result is not None + assert result.id == friendship_id + + @pytest.mark.asyncio + async def test_get_friendship_by_id_returns_none( + self, repository: Any, mock_session: MagicMock + ) -> None: + friendship_id = uuid.uuid4() + + class MockExecuteResult: + def scalar_one_or_none(self): + return None + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_friendship_by_id(friendship_id) + + assert result is None + + @pytest.mark.asyncio + async def test_get_inbox_messages_for_user_returns_messages( + self, repository: Any, mock_session: MagicMock + ) -> None: + user_id = uuid.uuid4() + expected_messages = [ + InboxMessage( + id=uuid.uuid4(), + recipient_id=user_id, + sender_id=uuid.uuid4(), + message_type=InboxMessageType.FRIEND_REQUEST, + status=InboxMessageStatus.PENDING, + ), + InboxMessage( + id=uuid.uuid4(), + recipient_id=user_id, + sender_id=uuid.uuid4(), + message_type=InboxMessageType.FRIEND_REQUEST, + status=InboxMessageStatus.ACCEPTED, + ), + ] + + class MockScalars: + def all(self): + return expected_messages + + class MockExecuteResult: + def scalars(self): + return MockScalars() + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_inbox_messages_for_user(user_id) + + assert len(result) == 2 + + @pytest.mark.asyncio + async def test_get_inbox_messages_for_user_filters_by_status( + self, repository: Any, mock_session: MagicMock + ) -> None: + user_id = uuid.uuid4() + expected_messages = [ + InboxMessage( + id=uuid.uuid4(), + recipient_id=user_id, + sender_id=uuid.uuid4(), + message_type=InboxMessageType.FRIEND_REQUEST, + status=InboxMessageStatus.PENDING, + ), + ] + + class MockScalars: + def all(self): + return expected_messages + + class MockExecuteResult: + def scalars(self): + return MockScalars() + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_inbox_messages_for_user( + user_id, status=InboxMessageStatus.PENDING + ) + + assert len(result) == 1 + assert result[0].status == InboxMessageStatus.PENDING + + @pytest.mark.asyncio + async def test_get_outgoing_requests_returns_pending_requests( + self, repository: Any, mock_session: MagicMock + ) -> None: + user_id = uuid.uuid4() + expected_requests = [ + Friendship( + id=uuid.uuid4(), + user_low_id=user_id, + user_high_id=uuid.uuid4(), + initiator_id=user_id, + status=FriendshipStatus.PENDING, + ), + ] + + class MockScalars: + def all(self): + return expected_requests + + class MockExecuteResult: + def scalars(self): + return MockScalars() + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_outgoing_requests(user_id) + + assert len(result) == 1 + assert result[0].initiator_id == user_id + assert result[0].status == FriendshipStatus.PENDING + + @pytest.mark.asyncio + async def test_get_friends_list_returns_accepted_friends( + self, repository: Any, mock_session: MagicMock + ) -> None: + user_id = uuid.uuid4() + friend_id = uuid.uuid4() + expected_friends = [ + Friendship( + id=uuid.uuid4(), + user_low_id=min(user_id, friend_id), + user_high_id=max(user_id, friend_id), + initiator_id=user_id, + status=FriendshipStatus.ACCEPTED, + accepted_at=uuid.uuid4(), + ), + ] + + class MockScalars: + def all(self): + return expected_friends + + class MockExecuteResult: + def scalars(self): + return MockScalars() + + mock_session.execute = AsyncMock(return_value=MockExecuteResult()) + + result = await repository.get_friends_list(user_id) + + assert len(result) == 1 + assert result[0].status == FriendshipStatus.ACCEPTED diff --git a/backend/tests/unit/v1/friendships/test_friendship_service.py b/backend/tests/unit/v1/friendships/test_friendship_service.py new file mode 100644 index 0000000..f018a4c --- /dev/null +++ b/backend/tests/unit/v1/friendships/test_friendship_service.py @@ -0,0 +1,582 @@ +from __future__ import annotations + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest +from fastapi import HTTPException + +from core.auth.models import CurrentUser +from models.friendships import Friendship, FriendshipStatus +from models.inbox_messages import InboxMessage, InboxMessageStatus, InboxMessageType +from v1.friendships.repository import FriendshipRepository +from v1.friendships.schemas import ( + FriendRequestCreate, +) +from v1.friendships.service import FriendshipService +from v1.users.repository import UserRepository + + +def _create_mock_profile( + user_id: UUID = UUID("00000000-0000-0000-0000-000000000001"), + username: str = "testuser", + avatar_url: str | None = None, +) -> MagicMock: + """Create a mock Profile ORM object.""" + profile = MagicMock() + profile.id = user_id + profile.username = username + profile.avatar_url = avatar_url + profile.bio = None + return profile + + +class FakeFriendshipRepo: + """Fake repository for testing that conforms to FriendshipRepository protocol.""" + + def __init__( + self, + friendships: list[Friendship] | None = None, + inbox_messages: list[InboxMessage] | None = None, + ) -> None: + self._friendships = friendships or [] + self._inbox_messages = inbox_messages or [] + + async def create_request( + self, initiator_id: UUID, recipient_id: UUID + ) -> tuple[Friendship, InboxMessage]: + friendship = MagicMock(spec=Friendship) + friendship.id = uuid4() + friendship.initiator_id = initiator_id + friendship.user_low_id = min(initiator_id, recipient_id) + friendship.user_high_id = max(initiator_id, recipient_id) + friendship.status = FriendshipStatus.PENDING + friendship.created_at = datetime.utcnow() + self._friendships.append(friendship) + + inbox = MagicMock(spec=InboxMessage) + inbox.id = uuid4() + inbox.recipient_id = recipient_id + inbox.sender_id = initiator_id + inbox.status = InboxMessageStatus.PENDING + inbox.message_type = InboxMessageType.FRIEND_REQUEST + inbox.friendship_id = friendship.id + inbox.content = None + self._inbox_messages.append(inbox) + + return friendship, inbox + + async def get_friendship_between_users( + self, user_id_1: UUID, user_id_2: UUID + ) -> Friendship | None: + for f in self._friendships: + user_low = min(user_id_1, user_id_2) + user_high = max(user_id_1, user_id_2) + if f.user_low_id == user_low and f.user_high_id == user_high: + return f + return None + + async def get_pending_inbox_for_recipient( + self, recipient_id: UUID, friendship_id: UUID + ) -> InboxMessage | None: + for msg in self._inbox_messages: + if msg.recipient_id == recipient_id and msg.friendship_id == friendship_id: + return msg + return None + + async def get_friendship_by_id(self, friendship_id: UUID) -> Friendship | None: + for f in self._friendships: + if f.id == friendship_id: + return f + return None + + async def get_inbox_messages_for_user( + self, user_id: UUID, status: InboxMessageStatus | None = None + ) -> list[InboxMessage]: + result = [msg for msg in self._inbox_messages if msg.recipient_id == user_id] + if status: + result = [msg for msg in result if msg.status == status] + return result + + async def get_outgoing_requests(self, user_id: UUID) -> list[Friendship]: + return [ + f + for f in self._friendships + if f.initiator_id == user_id and f.status == FriendshipStatus.PENDING + ] + + async def get_friends_list(self, user_id: UUID) -> list[Friendship]: + return [ + f + for f in self._friendships + if f.status == FriendshipStatus.ACCEPTED + and (f.user_low_id == user_id or f.user_high_id == user_id) + ] + + +class FakeUserRepo: + """Fake user repository for testing.""" + + def __init__(self, profiles: dict[UUID, MagicMock] | None = None) -> None: + self._profiles = profiles or {} + + async def get_by_user_id(self, user_id: UUID) -> MagicMock | None: + return self._profiles.get(user_id) + + async def get_by_username(self, username: str) -> MagicMock | None: + for profile in self._profiles.values(): + if profile.username == username: + return profile + return None + + +_repo_check: FriendshipRepository = FakeFriendshipRepo() +_user_repo_check: UserRepository = FakeUserRepo() + +USER_A = UUID("00000000-0000-0000-0000-000000000001") +USER_B = UUID("00000000-0000-0000-0000-000000000002") +USER_C = UUID("00000000-0000-0000-0000-000000000003") + + +@pytest.fixture +def mock_session() -> AsyncMock: + session = AsyncMock() + session.commit = AsyncMock() + session.rollback = AsyncMock() + return session + + +@pytest.fixture +def current_user() -> CurrentUser: + return CurrentUser(id=USER_A) + + +@pytest.fixture +def mock_friendship_repo() -> FakeFriendshipRepo: + return FakeFriendshipRepo() + + +@pytest.fixture +def mock_user_repo() -> FakeUserRepo: + return FakeUserRepo( + { + USER_A: _create_mock_profile(USER_A, "user_a"), + USER_B: _create_mock_profile(USER_B, "user_b"), + USER_C: _create_mock_profile(USER_C, "user_c"), + } + ) + + +class TestSendRequest: + @pytest.mark.asyncio + async def test_send_request_creates_friendship_and_inbox( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + result = await service.send_request(FriendRequestCreate(target_user_id=USER_B)) + + assert result is not None + mock_session.commit.assert_awaited_once() + + @pytest.mark.asyncio + async def test_send_request_to_self_raises_400( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + with pytest.raises(HTTPException) as exc_info: + await service.send_request( + FriendRequestCreate(target_user_id=current_user.id) + ) + + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_send_request_to_existing_friend_raises_400( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + existing_friendship = MagicMock(spec=Friendship) + existing_friendship.id = uuid4() + existing_friendship.user_low_id = min(USER_A, USER_B) + existing_friendship.user_high_id = max(USER_A, USER_B) + existing_friendship.status = FriendshipStatus.ACCEPTED + mock_friendship_repo._friendships.append(existing_friendship) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + with pytest.raises(HTTPException) as exc_info: + await service.send_request(FriendRequestCreate(target_user_id=USER_B)) + + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_send_request_when_blocked_raises_400( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + blocked_friendship = MagicMock(spec=Friendship) + blocked_friendship.id = uuid4() + blocked_friendship.user_low_id = min(USER_A, USER_B) + blocked_friendship.user_high_id = max(USER_A, USER_B) + blocked_friendship.status = FriendshipStatus.BLOCKED + mock_friendship_repo._friendships.append(blocked_friendship) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + with pytest.raises(HTTPException) as exc_info: + await service.send_request(FriendRequestCreate(target_user_id=USER_B)) + + assert exc_info.value.status_code == 400 + + +class TestAcceptRequest: + @pytest.mark.asyncio + async def test_accept_request_updates_status( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + friendship = MagicMock(spec=Friendship) + friendship.id = uuid4() + friendship.initiator_id = USER_B + friendship.user_low_id = min(USER_A, USER_B) + friendship.user_high_id = max(USER_A, USER_B) + friendship.status = FriendshipStatus.PENDING + mock_friendship_repo._friendships.append(friendship) + + inbox = MagicMock(spec=InboxMessage) + inbox.id = uuid4() + inbox.recipient_id = USER_A # current_user is the recipient + inbox.content = None + inbox.status = InboxMessageStatus.PENDING + inbox.friendship_id = friendship.id + mock_friendship_repo._inbox_messages.append(inbox) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + result = await service.accept_request(friendship.id) + + assert result.status == "accepted" + mock_session.commit.assert_awaited_once() + + @pytest.mark.asyncio + async def test_accept_nonexistent_request_raises_404( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + with pytest.raises(HTTPException) as exc_info: + await service.accept_request(uuid4()) + + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_accept_request_not_recipient_raises_403( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + friendship = MagicMock(spec=Friendship) + friendship.id = uuid4() + friendship.initiator_id = USER_A # current user is the initiator + friendship.user_low_id = min(USER_A, USER_B) + friendship.user_high_id = max(USER_A, USER_B) + friendship.status = FriendshipStatus.PENDING + mock_friendship_repo._friendships.append(friendship) + + inbox = MagicMock(spec=InboxMessage) + inbox.id = uuid4() + inbox.recipient_id = USER_B # the other user is the recipient + inbox.content = None + inbox.status = InboxMessageStatus.PENDING + inbox.friendship_id = friendship.id + mock_friendship_repo._inbox_messages.append(inbox) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + with pytest.raises(HTTPException) as exc_info: + await service.accept_request(friendship.id) + + assert exc_info.value.status_code == 403 + + +class TestDeclineRequest: + @pytest.mark.asyncio + async def test_decline_request_updates_status( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + friendship = MagicMock(spec=Friendship) + friendship.id = uuid4() + friendship.initiator_id = USER_B + friendship.user_low_id = min(USER_A, USER_B) + friendship.user_high_id = max(USER_A, USER_B) + friendship.status = FriendshipStatus.PENDING + mock_friendship_repo._friendships.append(friendship) + + inbox = MagicMock(spec=InboxMessage) + inbox.id = uuid4() + inbox.recipient_id = USER_A + inbox.content = None + inbox.status = InboxMessageStatus.PENDING + inbox.friendship_id = friendship.id + mock_friendship_repo._inbox_messages.append(inbox) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + result = await service.decline_request(friendship.id) + + assert result.status == "rejected" + mock_session.commit.assert_awaited_once() + + +class TestCancelRequest: + @pytest.mark.asyncio + async def test_cancel_request_removes_request( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + friendship = MagicMock(spec=Friendship) + friendship.id = uuid4() + friendship.initiator_id = current_user.id + friendship.user_low_id = min(USER_A, USER_B) + friendship.user_high_id = max(USER_A, USER_B) + friendship.status = FriendshipStatus.PENDING + mock_friendship_repo._friendships.append(friendship) + + inbox = MagicMock(spec=InboxMessage) + inbox.id = uuid4() + inbox.recipient_id = USER_B # The other user + inbox.content = None + inbox.status = InboxMessageStatus.PENDING + inbox.friendship_id = friendship.id + mock_friendship_repo._inbox_messages.append(inbox) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + result = await service.cancel_request(friendship.id) + + assert result.status == "canceled" + mock_session.commit.assert_awaited_once() + + +class TestGetInbox: + @pytest.mark.asyncio + async def test_get_inbox_returns_pending_messages( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + friendship = MagicMock(spec=Friendship) + friendship.id = uuid4() + friendship.initiator_id = USER_B + friendship.status = FriendshipStatus.PENDING + mock_friendship_repo._friendships.append(friendship) + + inbox = MagicMock(spec=InboxMessage) + inbox.id = uuid4() + inbox.recipient_id = current_user.id + inbox.sender_id = USER_B + inbox.content = None + inbox.status = InboxMessageStatus.PENDING + inbox.message_type = InboxMessageType.FRIEND_REQUEST + inbox.friendship_id = friendship.id + inbox.created_at = datetime.utcnow() + mock_friendship_repo._inbox_messages.append(inbox) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + result = await service.get_inbox() + + assert len(result) == 1 + + +class TestGetOutgoingRequests: + @pytest.mark.asyncio + async def test_get_outgoing_requests_returns_sent_requests( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + friendship = MagicMock(spec=Friendship) + friendship.id = uuid4() + friendship.initiator_id = current_user.id + friendship.status = FriendshipStatus.PENDING + mock_friendship_repo._friendships.append(friendship) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + result = await service.get_outgoing_requests() + + assert len(result) == 1 + + +class TestGetFriendsList: + @pytest.mark.asyncio + async def test_get_friends_list_returns_accepted_friends( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + friendship = MagicMock(spec=Friendship) + friendship.id = uuid4() + friendship.user_low_id = current_user.id + friendship.user_high_id = USER_B + friendship.status = FriendshipStatus.ACCEPTED + friendship.created_at = datetime.utcnow() + friendship.updated_at = datetime.utcnow() + mock_friendship_repo._friendships.append(friendship) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + result = await service.get_friends_list() + + assert len(result) == 1 + + +class TestRemoveFriend: + @pytest.mark.asyncio + async def test_remove_friend_soft_deletes( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + friendship = MagicMock(spec=Friendship) + friendship.id = uuid4() + friendship.user_low_id = min(current_user.id, USER_B) + friendship.user_high_id = max(current_user.id, USER_B) + friendship.status = FriendshipStatus.ACCEPTED + friendship.created_at = datetime.utcnow() + friendship.updated_at = datetime.utcnow() + mock_friendship_repo._friendships.append(friendship) + + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + result = await service.remove_friend(USER_B) + + assert result.status == "active" + mock_session.commit.assert_awaited_once() + + @pytest.mark.asyncio + async def test_remove_nonexistent_friend_raises_404( + self, + mock_session: AsyncMock, + mock_friendship_repo: FakeFriendshipRepo, + mock_user_repo: FakeUserRepo, + current_user: CurrentUser, + ) -> None: + service = FriendshipService( + repository=mock_friendship_repo, + user_repository=mock_user_repo, + session=mock_session, + current_user=current_user, + ) + + with pytest.raises(HTTPException) as exc_info: + await service.remove_friend(uuid4()) + + assert exc_info.value.status_code == 404