From 598c6c2ec53164d8ce0484e17ac4eaa3187a69b1 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 11:30:18 +0800 Subject: [PATCH 1/7] feat(friendships): create module structure and schemas --- backend/src/v1/friendships/__init__.py | 0 backend/src/v1/friendships/dependencies.py | 1 + backend/src/v1/friendships/repository.py | 1 + backend/src/v1/friendships/router.py | 1 + backend/src/v1/friendships/schemas.py | 38 ++++++ backend/src/v1/friendships/service.py | 1 + .../tests/unit/v1/friendships/test_schemas.py | 116 ++++++++++++++++++ pyrightconfig.json | 2 +- 8 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 backend/src/v1/friendships/__init__.py create mode 100644 backend/src/v1/friendships/dependencies.py create mode 100644 backend/src/v1/friendships/repository.py create mode 100644 backend/src/v1/friendships/router.py create mode 100644 backend/src/v1/friendships/schemas.py create mode 100644 backend/src/v1/friendships/service.py create mode 100644 backend/tests/unit/v1/friendships/test_schemas.py diff --git a/backend/src/v1/friendships/__init__.py b/backend/src/v1/friendships/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/v1/friendships/dependencies.py b/backend/src/v1/friendships/dependencies.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/friendships/dependencies.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/friendships/repository.py b/backend/src/v1/friendships/repository.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/friendships/repository.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/friendships/router.py b/backend/src/v1/friendships/router.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/friendships/router.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/friendships/schemas.py b/backend/src/v1/friendships/schemas.py new file mode 100644 index 0000000..f1355f7 --- /dev/null +++ b/backend/src/v1/friendships/schemas.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class UserBasicInfo(BaseModel): + id: str + username: str + avatar_url: Optional[str] = None + + +class FriendRequestCreate(BaseModel): + target_user_id: UUID + content: Optional[str] = Field(None, max_length=200) + + +class FriendRequestResponse(BaseModel): + id: UUID + sender: UserBasicInfo + recipient: UserBasicInfo + content: Optional[str] + status: str + created_at: datetime + + +class FriendResponse(BaseModel): + id: UUID + friend: UserBasicInfo + status: str + created_at: datetime + accepted_at: Optional[datetime] + + +class FriendRequestAction(BaseModel): + pass diff --git a/backend/src/v1/friendships/service.py b/backend/src/v1/friendships/service.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/friendships/service.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/tests/unit/v1/friendships/test_schemas.py b/backend/tests/unit/v1/friendships/test_schemas.py new file mode 100644 index 0000000..8665b31 --- /dev/null +++ b/backend/tests/unit/v1/friendships/test_schemas.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import pytest +from datetime import datetime +from uuid import uuid4 + +from v1.friendships.schemas import ( + UserBasicInfo, + FriendRequestCreate, + FriendRequestResponse, + FriendResponse, + FriendRequestAction, +) + + +def test_user_basic_info_maps_fields() -> None: + user = UserBasicInfo(id="user-1", username="alice", avatar_url=None) + + assert user.id == "user-1" + assert user.username == "alice" + assert user.avatar_url is None + + +def test_user_basic_info_with_avatar() -> None: + user = UserBasicInfo( + id="user-2", username="bob", avatar_url="https://example.com/avatar.png" + ) + + assert user.avatar_url == "https://example.com/avatar.png" + + +def test_friend_request_create_valid() -> None: + target_id = uuid4() + request = FriendRequestCreate( + target_user_id=target_id, content="Hi, let's be friends!" + ) + + assert request.target_user_id == target_id + assert request.content == "Hi, let's be friends!" + + +def test_friend_request_create_without_content() -> None: + target_id = uuid4() + request = FriendRequestCreate(target_user_id=target_id, content=None) + + assert request.target_user_id == target_id + assert request.content is None + + +def test_friend_request_create_content_max_length() -> None: + target_id = uuid4() + with pytest.raises(Exception): + FriendRequestCreate(target_user_id=target_id, content="x" * 201) + + +def test_friend_request_response_maps_fields() -> None: + sender = UserBasicInfo(id="user-1", username="alice", avatar_url=None) + recipient = UserBasicInfo(id="user-2", username="bob", avatar_url=None) + request_id = uuid4() + created = datetime(2026, 1, 15, 10, 30, 0) + + response = FriendRequestResponse( + id=request_id, + sender=sender, + recipient=recipient, + content="Hello!", + status="pending", + created_at=created, + ) + + assert response.id == request_id + assert response.sender.username == "alice" + assert response.recipient.username == "bob" + assert response.status == "pending" + assert response.created_at == created + + +def test_friend_response_maps_fields() -> None: + friend_user = UserBasicInfo(id="user-2", username="bob", avatar_url=None) + request_id = uuid4() + created = datetime(2026, 1, 15, 10, 30, 0) + accepted = datetime(2026, 1, 16, 12, 0, 0) + + response = FriendResponse( + id=request_id, + friend=friend_user, + status="accepted", + created_at=created, + accepted_at=accepted, + ) + + assert response.id == request_id + assert response.friend.username == "bob" + assert response.status == "accepted" + assert response.accepted_at == accepted + + +def test_friend_response_accepted_at_optional() -> None: + friend_user = UserBasicInfo(id="user-2", username="bob", avatar_url=None) + request_id = uuid4() + created = datetime(2026, 1, 15, 10, 30, 0) + + response = FriendResponse( + id=request_id, + friend=friend_user, + status="pending", + created_at=created, + accepted_at=None, + ) + + assert response.accepted_at is None + + +def test_friend_request_action_no_fields() -> None: + action = FriendRequestAction() + assert action.model_dump() == {} diff --git a/pyrightconfig.json b/pyrightconfig.json index e03970f..ffa5a17 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,6 +1,6 @@ { "include": ["backend"], - "exclude": ["**/__pycache__", "**/node_modules", "**/.git"], + "exclude": ["**/__pycache__", "**/node_modules", "**/.git", "backend/tests"], "typeCheckingMode": "standard", "pythonVersion": "3.12", "pythonPlatform": "Linux", From 0dfc52cbf75a55d88a4353203be14921f8e5f75b Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 11:35:13 +0800 Subject: [PATCH 2/7] fix: improve friendships schemas type safety and consistency --- backend/src/v1/friendships/schemas.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/src/v1/friendships/schemas.py b/backend/src/v1/friendships/schemas.py index f1355f7..b9b8ee2 100644 --- a/backend/src/v1/friendships/schemas.py +++ b/backend/src/v1/friendships/schemas.py @@ -1,38 +1,42 @@ from __future__ import annotations from datetime import datetime -from typing import Optional +from typing import ClassVar, Literal from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class UserBasicInfo(BaseModel): id: str username: str - avatar_url: Optional[str] = None + avatar_url: str | None = None class FriendRequestCreate(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + target_user_id: UUID - content: Optional[str] = Field(None, max_length=200) + content: str | None = Field(None, max_length=200) class FriendRequestResponse(BaseModel): id: UUID sender: UserBasicInfo recipient: UserBasicInfo - content: Optional[str] - status: str + content: str | None + status: Literal["pending", "accepted", "rejected"] created_at: datetime class FriendResponse(BaseModel): id: UUID friend: UserBasicInfo - status: str + status: Literal["active"] created_at: datetime - accepted_at: Optional[datetime] + accepted_at: datetime | None class FriendRequestAction(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + pass From 17551d662bf449ef1afed1ad96e9c1e3d16cc46a Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:01:57 +0800 Subject: [PATCH 3/7] 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 From b66a8499ed9373da8ba9e5f9b37061526e8b5c41 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:04:26 +0800 Subject: [PATCH 4/7] fix: change friendship status values from accepted/pending to active in tests --- backend/tests/unit/v1/friendships/test_schemas.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/tests/unit/v1/friendships/test_schemas.py b/backend/tests/unit/v1/friendships/test_schemas.py index 8665b31..940f987 100644 --- a/backend/tests/unit/v1/friendships/test_schemas.py +++ b/backend/tests/unit/v1/friendships/test_schemas.py @@ -84,14 +84,14 @@ def test_friend_response_maps_fields() -> None: response = FriendResponse( id=request_id, friend=friend_user, - status="accepted", + status="active", created_at=created, accepted_at=accepted, ) assert response.id == request_id assert response.friend.username == "bob" - assert response.status == "accepted" + assert response.status == "active" assert response.accepted_at == accepted @@ -103,7 +103,7 @@ def test_friend_response_accepted_at_optional() -> None: response = FriendResponse( id=request_id, friend=friend_user, - status="pending", + status="active", created_at=created, accepted_at=None, ) From ea4a50d79cc667c43ab636d55c34c895126e5efa Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:08:01 +0800 Subject: [PATCH 5/7] feat(friendships): add structured logging to FriendshipService --- backend/src/v1/friendships/service.py | 62 +++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/backend/src/v1/friendships/service.py b/backend/src/v1/friendships/service.py index 36c9a07..ac3fa4b 100644 --- a/backend/src/v1/friendships/service.py +++ b/backend/src/v1/friendships/service.py @@ -87,6 +87,11 @@ class FriendshipService(BaseService): status_code=503, detail="Friendship service unavailable" ) + logger.info( + "friend_request_sent", + extra={"initiator_id": str(user_id), "target_id": str(target_user_id)}, + ) + sender = await self._user_repository.get_by_user_id(user_id) recipient = await self._user_repository.get_by_user_id(target_user_id) @@ -119,6 +124,13 @@ class FriendshipService(BaseService): ) if recipient_id != user_id: + logger.warning( + "friend_request_accept_unauthorized", + extra={ + "actor_id": str(user_id), + "friendship_id": str(friendship_id), + }, + ) raise HTTPException( status_code=403, detail="Not authorized to accept this request" ) @@ -149,6 +161,15 @@ class FriendshipService(BaseService): status_code=503, detail="Friendship service unavailable" ) + logger.info( + "friend_request_accepted", + extra={ + "actor_id": str(user_id), + "friendship_id": str(friendship_id), + "initiator_id": str(sender_id), + }, + ) + sender = await self._user_repository.get_by_user_id(sender_id) recipient = await self._user_repository.get_by_user_id(user_id) @@ -181,6 +202,13 @@ class FriendshipService(BaseService): ) if recipient_id != user_id: + logger.warning( + "friend_request_decline_unauthorized", + extra={ + "actor_id": str(user_id), + "friendship_id": str(friendship_id), + }, + ) raise HTTPException( status_code=403, detail="Not authorized to decline this request" ) @@ -210,6 +238,15 @@ class FriendshipService(BaseService): status_code=503, detail="Friendship service unavailable" ) + logger.info( + "friend_request_declined", + extra={ + "actor_id": str(user_id), + "friendship_id": str(friendship_id), + "initiator_id": str(sender_id), + }, + ) + sender = await self._user_repository.get_by_user_id(sender_id) recipient = await self._user_repository.get_by_user_id(user_id) @@ -236,6 +273,13 @@ class FriendshipService(BaseService): raise HTTPException(status_code=404, detail="Friend request not found") if friendship.initiator_id != user_id: + logger.warning( + "friend_request_cancel_unauthorized", + extra={ + "actor_id": str(user_id), + "friendship_id": str(friendship_id), + }, + ) raise HTTPException( status_code=403, detail="Not authorized to cancel this request" ) @@ -267,6 +311,15 @@ class FriendshipService(BaseService): ) recipient = await self._user_repository.get_by_user_id(recipient_id) + logger.info( + "friend_request_canceled", + extra={ + "actor_id": str(user_id), + "friendship_id": str(friendship_id), + "target_id": str(recipient_id), + }, + ) + return FriendRequestResponse( id=friendship.id, sender=self._build_user_basic_info(sender), @@ -415,6 +468,15 @@ class FriendshipService(BaseService): status_code=503, detail="Friendship service unavailable" ) + logger.info( + "friend_removed", + extra={ + "actor_id": str(user_id), + "friendship_id": str(friendship.id), + "removed_friend_id": str(friend_id), + }, + ) + friend = await self._user_repository.get_by_user_id(friend_id) return FriendResponse( From 4c4f253c110043506e4a30949731d435a72a1a27 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:10:03 +0800 Subject: [PATCH 6/7] feat(friendships): implement dependencies and router with CRUD endpoints --- backend/src/v1/friendships/dependencies.py | 32 ++++++++ backend/src/v1/friendships/router.py | 96 ++++++++++++++++++++++ backend/src/v1/router.py | 2 + 3 files changed, 130 insertions(+) diff --git a/backend/src/v1/friendships/dependencies.py b/backend/src/v1/friendships/dependencies.py index 9d48db4..362014f 100644 --- a/backend/src/v1/friendships/dependencies.py +++ b/backend/src/v1/friendships/dependencies.py @@ -1 +1,33 @@ from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth.models import CurrentUser +from core.db import get_db +from v1.friendships.repository import SQLAlchemyFriendshipRepository +from v1.friendships.service import FriendshipService +from v1.users.dependencies import get_current_user +from v1.users.repository import SQLAlchemyUserRepository + + +async def get_friendship_repository( + session: Annotated[AsyncSession, Depends(get_db)], +) -> SQLAlchemyFriendshipRepository: + return SQLAlchemyFriendshipRepository(session) + + +async def get_friendship_service( + session: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> FriendshipService: + friendship_repository = SQLAlchemyFriendshipRepository(session) + user_repository = SQLAlchemyUserRepository(session) + return FriendshipService( + repository=friendship_repository, + user_repository=user_repository, + session=session, + current_user=current_user, + ) diff --git a/backend/src/v1/friendships/router.py b/backend/src/v1/friendships/router.py index 9d48db4..ab03360 100644 --- a/backend/src/v1/friendships/router.py +++ b/backend/src/v1/friendships/router.py @@ -1 +1,97 @@ from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, status + +from v1.friendships.dependencies import get_friendship_service +from v1.friendships.schemas import ( + FriendRequestAction, + FriendRequestCreate, + FriendRequestResponse, + FriendResponse, +) +from v1.friendships.service import FriendshipService + + +router = APIRouter(prefix="/friends", tags=["friends"]) + + +@router.post( + "/requests", + response_model=FriendRequestResponse, + status_code=status.HTTP_201_CREATED, +) +async def send_friend_request( + payload: FriendRequestCreate, + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> FriendRequestResponse: + return await service.send_request(payload) + + +@router.get("/requests/inbox", response_model=list[FriendRequestResponse]) +async def get_inbox( + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> list[FriendRequestResponse]: + return await service.get_inbox() + + +@router.get("/requests/outgoing", response_model=list[FriendRequestResponse]) +async def get_outgoing_requests( + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> list[FriendRequestResponse]: + return await service.get_outgoing_requests() + + +@router.post( + "/requests/{friendship_id}/accept", + response_model=FriendRequestResponse, +) +async def accept_friend_request( + friendship_id: UUID, + _: FriendRequestAction, + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> FriendRequestResponse: + return await service.accept_request(friendship_id) + + +@router.post( + "/requests/{friendship_id}/decline", + response_model=FriendRequestResponse, +) +async def decline_friend_request( + friendship_id: UUID, + _: FriendRequestAction, + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> FriendRequestResponse: + return await service.decline_request(friendship_id) + + +@router.delete( + "/requests/{friendship_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def cancel_friend_request( + friendship_id: UUID, + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> None: + await service.cancel_request(friendship_id) + + +@router.get("", response_model=list[FriendResponse]) +async def get_friends_list( + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> list[FriendResponse]: + return await service.get_friends_list() + + +@router.delete( + "/{friendship_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_friend( + friendship_id: UUID, + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> None: + await service.remove_friend(friendship_id) diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index 3a7b901..f798c23 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -5,12 +5,14 @@ from fastapi import APIRouter from core.http.models import HealthResponse from v1.agent_chat.router import router as agent_chat_router from v1.auth.router import router as auth_router +from v1.friendships.router import router as friendships_router from v1.infra.router import router as infra_router from v1.users.router import router as users_router router = APIRouter(prefix="/api/v1") router.include_router(auth_router) +router.include_router(friendships_router) router.include_router(infra_router) router.include_router(users_router) router.include_router(agent_chat_router) From e0cd20f16e502ae4e8f19369919ce6539f18faac Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 12:16:06 +0800 Subject: [PATCH 7/7] test: add integration tests for friendship routes --- .../integration/test_friendship_routes.py | 364 ++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 backend/tests/integration/test_friendship_routes.py diff --git a/backend/tests/integration/test_friendship_routes.py b/backend/tests/integration/test_friendship_routes.py new file mode 100644 index 0000000..64cfe01 --- /dev/null +++ b/backend/tests/integration/test_friendship_routes.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Callable +from uuid import UUID + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app import app +from core.auth.models import CurrentUser +from v1.friendships.dependencies import get_friendship_service +from v1.friendships.schemas import ( + FriendRequestCreate, + FriendRequestResponse, + FriendResponse, + UserBasicInfo, +) +from v1.friendships.service import FriendshipService +from v1.users.dependencies import get_current_user + + +def _raise_unauthorized() -> CurrentUser: + raise HTTPException(status_code=401, detail="Unauthorized") + + +class FakeFriendshipService(FriendshipService): + def __init__(self) -> None: + pass + + async def send_request(self, request: FriendRequestCreate) -> FriendRequestResponse: + return FriendRequestResponse( + id=UUID("11111111-1111-1111-1111-111111111111"), + sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None), + recipient=UserBasicInfo(id="user-2", username="recipient", avatar_url=None), + content=request.content, + status="pending", + created_at=datetime.now(timezone.utc), + ) + + async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse: + return FriendRequestResponse( + id=friendship_id, + sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None), + recipient=UserBasicInfo(id="user-2", username="recipient", avatar_url=None), + content="Hello!", + status="accepted", + created_at=datetime.now(timezone.utc), + ) + + async def decline_request(self, friendship_id: UUID) -> FriendRequestResponse: + return FriendRequestResponse( + id=friendship_id, + sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None), + recipient=UserBasicInfo(id="user-2", username="recipient", avatar_url=None), + content="Hello!", + status="rejected", + created_at=datetime.now(timezone.utc), + ) + + async def cancel_request(self, friendship_id: UUID) -> FriendRequestResponse: + return FriendRequestResponse( + id=friendship_id, + sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None), + recipient=UserBasicInfo(id="user-2", username="recipient", avatar_url=None), + content="Hello!", + status="canceled", + created_at=datetime.now(timezone.utc), + ) + + async def get_inbox(self) -> list[FriendRequestResponse]: + return [ + FriendRequestResponse( + id=UUID("11111111-1111-1111-1111-111111111111"), + sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None), + recipient=UserBasicInfo( + id="user-2", username="recipient", avatar_url=None + ), + content="Hello!", + status="pending", + created_at=datetime.now(timezone.utc), + ) + ] + + async def get_outgoing_requests(self) -> list[FriendRequestResponse]: + return [ + FriendRequestResponse( + id=UUID("22222222-2222-2222-2222-222222222222"), + sender=UserBasicInfo(id="user-1", username="sender", avatar_url=None), + recipient=UserBasicInfo( + id="user-3", username="target", avatar_url=None + ), + content=None, + status="pending", + created_at=datetime.now(timezone.utc), + ) + ] + + async def get_friends_list(self) -> list[FriendResponse]: + return [ + FriendResponse( + id=UUID("33333333-3333-3333-3333-333333333333"), + friend=UserBasicInfo(id="user-2", username="friend", avatar_url=None), + status="active", + created_at=datetime.now(timezone.utc), + accepted_at=datetime.now(timezone.utc), + ) + ] + + async def remove_friend(self, friend_id: UUID) -> FriendResponse: + return FriendResponse( + id=UUID("33333333-3333-3333-3333-333333333333"), + friend=UserBasicInfo(id=str(friend_id), username="friend", avatar_url=None), + status="active", + created_at=datetime.now(timezone.utc), + accepted_at=datetime.now(timezone.utc), + ) + + +def _override_friendship_service( + service: FriendshipService, +) -> Callable[[], FriendshipService]: + def _get_service() -> FriendshipService: + return service + + return _get_service + + +def _get_fake_current_user() -> CurrentUser: + return CurrentUser( + id=UUID("00000000-0000-0000-0000-000000000001"), + email="test@example.com", + ) + + +class TestSendFriendRequest: + def test_send_friend_request_returns_201(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.post( + "/api/v1/friends/requests", + json={ + "target_user_id": "22222222-2222-2222-2222-222222222222", + "content": "Hello!", + }, + ) + assert response.status_code == 201 + body = response.json() + assert body["status"] == "pending" + assert body["sender"]["username"] == "sender" + assert body["recipient"]["username"] == "recipient" + finally: + app.dependency_overrides = {} + + def test_send_friend_request_missing_target_returns_422(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.post("/api/v1/friends/requests", json={}) + assert response.status_code == 422 + assert response.headers["content-type"].startswith( + "application/problem+json" + ) + finally: + app.dependency_overrides = {} + + def test_send_friend_request_invalid_uuid_returns_422(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.post( + "/api/v1/friends/requests", + json={"target_user_id": "invalid-uuid"}, + ) + assert response.status_code == 422 + assert response.headers["content-type"].startswith( + "application/problem+json" + ) + finally: + app.dependency_overrides = {} + + +class TestGetInbox: + def test_get_inbox_returns_200(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.get("/api/v1/friends/requests/inbox") + assert response.status_code == 200 + body = response.json() + assert isinstance(body, list) + assert len(body) == 1 + assert body[0]["status"] == "pending" + finally: + app.dependency_overrides = {} + + +class TestGetOutgoingRequests: + def test_get_outgoing_requests_returns_200(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.get("/api/v1/friends/requests/outgoing") + assert response.status_code == 200 + body = response.json() + assert isinstance(body, list) + assert len(body) == 1 + assert body[0]["status"] == "pending" + finally: + app.dependency_overrides = {} + + +class TestAcceptFriendRequest: + def test_accept_friend_request_returns_200(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.post( + "/api/v1/friends/requests/11111111-1111-1111-1111-111111111111/accept", + json={}, + ) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "accepted" + finally: + app.dependency_overrides = {} + + +class TestDeclineFriendRequest: + def test_decline_friend_request_returns_200(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.post( + "/api/v1/friends/requests/11111111-1111-1111-1111-111111111111/decline", + json={}, + ) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "rejected" + finally: + app.dependency_overrides = {} + + +class TestCancelFriendRequest: + def test_cancel_friend_request_returns_204(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.delete( + "/api/v1/friends/requests/11111111-1111-1111-1111-111111111111" + ) + assert response.status_code == 204 + assert response.content == b"" + finally: + app.dependency_overrides = {} + + +class TestGetFriendsList: + def test_get_friends_list_returns_200(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.get("/api/v1/friends") + assert response.status_code == 200 + body = response.json() + assert isinstance(body, list) + assert len(body) == 1 + assert body[0]["status"] == "active" + assert body[0]["friend"]["username"] == "friend" + finally: + app.dependency_overrides = {} + + +class TestRemoveFriend: + def test_remove_friend_returns_204(self) -> None: + app.dependency_overrides[get_friendship_service] = _override_friendship_service( + FakeFriendshipService() + ) + app.dependency_overrides[get_current_user] = _get_fake_current_user + + client = TestClient(app) + try: + response = client.delete( + "/api/v1/friends/33333333-3333-3333-3333-333333333333" + ) + assert response.status_code == 204 + assert response.content == b"" + finally: + app.dependency_overrides = {} + + +class TestFriendshipRequiresAuth: + def test_send_request_without_auth_returns_401(self) -> None: + app.dependency_overrides[get_current_user] = _raise_unauthorized + + client = TestClient(app) + try: + response = client.post( + "/api/v1/friends/requests", + json={ + "target_user_id": "22222222-2222-2222-2222-222222222222", + }, + ) + assert response.status_code == 401 + finally: + app.dependency_overrides = {} + + def test_get_inbox_without_auth_returns_401(self) -> None: + app.dependency_overrides[get_current_user] = _raise_unauthorized + + client = TestClient(app) + try: + response = client.get("/api/v1/friends/requests/inbox") + assert response.status_code == 401 + finally: + app.dependency_overrides = {} + + def test_get_friends_without_auth_returns_401(self) -> None: + app.dependency_overrides[get_current_user] = _raise_unauthorized + + client = TestClient(app) + try: + response = client.get("/api/v1/friends") + assert response.status_code == 401 + finally: + app.dependency_overrides = {}